Skip to content

HTML Template Tutorial

In this tutorial you will build a small server-rendered website using the html tagged template. You will learn how to compose templates, safely handle user input, render lists, and return HTML responses from a router handler.

Prerequisites

Install Remix:

bash
npm install remix

All imports come from a single path:

ts
import { html, isSafeHtml } from 'remix/html-template'

Step 1 --- Your First Template

The html tag returns a SafeHtml value. Convert it to a string with String() when you need raw HTML:

ts
import { html } from 'remix/html-template'

let page = html`
  <!DOCTYPE html>
  <html lang="en">
    <head><title>My Site</title></head>
    <body>
      <h1>Hello, World</h1>
    </body>
  </html>
`

console.log(String(page))

Step 2 --- Interpolating Values Safely

Every interpolation is HTML-escaped. Try passing a string with angle brackets:

ts
let userName = 'Alice <admin>'

let greeting = html`<p>Welcome, ${userName}!</p>`
// <p>Welcome, Alice &lt;admin&gt;!</p>

This means you can safely interpolate any user-supplied value without worrying about XSS.

Step 3 --- Composing Templates

Break your page into reusable functions. Each function returns SafeHtml, and nested SafeHtml values are not double-escaped:

ts
function layout(title: string, content: ReturnType<typeof html>) {
  return html`
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>${title}</title>
        <style>
          body { font-family: sans-serif; margin: 2rem; }
          nav { margin-bottom: 1rem; }
          nav a { margin-right: 1rem; }
        </style>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <a href="/about">About</a>
          <a href="/contact">Contact</a>
        </nav>
        <main>${content}</main>
      </body>
    </html>
  `
}

function homePage() {
  return layout('Home', html`
    <h1>Welcome</h1>
    <p>This is the home page.</p>
  `)
}

function aboutPage() {
  return layout('About', html`
    <h1>About Us</h1>
    <p>We build things with Remix.</p>
  `)
}

Step 4 --- Rendering Lists

Map over arrays to produce lists. The html tag accepts arrays of SafeHtml and concatenates them:

ts
interface Product {
  name: string
  price: number
}

function productTable(products: Product[]) {
  let rows = products.map(
    (p) => html`<tr><td>${p.name}</td><td>$${p.price.toFixed(2)}</td></tr>`,
  )

  return html`
    <table>
      <thead><tr><th>Product</th><th>Price</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
  `
}

Step 5 --- Handling User Input in Forms

When displaying form values back to the user (for example, after validation fails), the html tag escapes them automatically:

ts
function contactForm(values: { name: string; message: string }, error?: string) {
  return layout('Contact', html`
    <h1>Contact Us</h1>
    ${error ? html`<p style="color: red;">${error}</p>` : html``}
    <form method="POST" action="/contact">
      <label>
        Name
        <input type="text" name="name" value="${values.name}" />
      </label>
      <label>
        Message
        <textarea name="message">${values.message}</textarea>
      </label>
      <button type="submit">Send</button>
    </form>
  `)
}

Even if values.name contains "><script>alert(1)</script>, it will be escaped in the output.

Conditional rendering

Use the ternary operator with html for conditional blocks. For "nothing", use html\`` (an empty template).

Step 6 --- Returning HTML Responses

Integrate with a Remix router handler by converting the template to a Response:

ts
import { html } from 'remix/html-template'

export function handleHome(request: Request): Response {
  let page = layout('Home', html`
    <h1>Welcome</h1>
    <p>The time is ${new Date().toLocaleTimeString()}.</p>
  `)

  return new Response(String(page), {
    headers: { 'Content-Type': 'text/html; charset=UTF-8' },
  })
}

export async function handleContact(request: Request): Response {
  if (request.method === 'POST') {
    let formData = await request.formData()
    let name = formData.get('name')?.toString() ?? ''
    let message = formData.get('message')?.toString() ?? ''

    if (!name || !message) {
      let page = contactForm({ name, message }, 'All fields are required.')
      return new Response(String(page), {
        status: 422,
        headers: { 'Content-Type': 'text/html; charset=UTF-8' },
      })
    }

    // Process the message...
    return Response.redirect('/contact?sent=1', 303)
  }

  let page = contactForm({ name: '', message: '' })
  return new Response(String(page), {
    headers: { 'Content-Type': 'text/html; charset=UTF-8' },
  })
}

What You Learned

  • The html tag escapes all interpolated values to prevent XSS.
  • SafeHtml values nest without double-escaping, making composition natural.
  • Arrays of SafeHtml are concatenated automatically --- useful for lists.
  • Convert to a string with String(page) and return it as a Response.
  • Use html.raw only for trusted content (not covered here --- see the reference).

Released under the MIT License.