Skip to main content
Safe HTML template literals for Remix. html-template automatically escapes interpolated values to prevent XSS while still supporting explicit trusted HTML insertion.

Features

  • Automatic HTML escaping - All interpolated values are escaped by default
  • Explicit raw HTML - Use html.raw when you need unescaped HTML from trusted sources
  • Composable - SafeHtml values can be nested without double-escaping
  • Type-safe - Full TypeScript support with branded types
  • Zero dependencies - Lightweight and self-contained
  • Runtime agnostic - Works in Node.js, Bun, Deno, browsers, and edge runtimes

Installation

npm i remix

Usage

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

let userInput = '<script>alert("XSS")</script>'
let greeting = html`<h1>Hello ${userInput}!</h1>`

console.log(String(greeting))
// Output: <h1>Hello &lt;script&gt;alert("XSS")&lt;/script&gt;!</h1>
By default, all interpolated values are automatically escaped to prevent XSS attacks.

Raw HTML

If you have trusted HTML that should not be escaped, use html.raw:
import { html } from 'remix/html-template'

let trustedIcon = '<svg>...</svg>'
let button = html.raw`<button>${trustedIcon} Click me</button>`

console.log(String(button))
// => <button><svg>...</svg> Click me</button>
Only use html.raw with content you trust. Never use it with user input.

Composing HTML Fragments

SafeHtml values can be nested without double-escaping:
import { html } from 'remix/html-template'

let title = html`<h1>My Title</h1>`
let content = html`<p>Some content with ${userInput}</p>`

let page = html`
  <!doctype html>
  <html>
    <body>
      ${title}
      ${content}
    </body>
  </html>
`

Working with Arrays

You can interpolate arrays of values, which will be flattened and joined:
import { html } from 'remix/html-template'

let items = ['Apple', 'Banana', 'Cherry']
let list = html`
  <ul>
    ${items.map((item) => html`<li>${item}</li>`)}
  </ul>
`

Conditional Rendering

Use null or undefined to render nothing:
import { html } from 'remix/html-template'

let showError = false
let errorMessage = 'Something went wrong'

let page = html`
  <div>
    ${showError ? html`<div class="error">${errorMessage}</div>` : null}
  </div>
`

Building Complete Pages

Combine with response helpers to create full HTML responses:
import { html } from 'remix/html-template'
import { createHtmlResponse } from 'remix/response/html'

async function handler(request: Request) {
  let user = { name: 'Alice' }
  
  let page = html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Welcome</title>
      </head>
      <body>
        <h1>Welcome, ${user.name}!</h1>
        <p>This content is safely escaped.</p>
      </body>
    </html>
  `
  
  return createHtmlResponse(page)
}

Building Forms

Create forms with dynamic values safely:
import { html } from 'remix/html-template'

function renderForm(data: { name: string; email: string }) {
  return html`
    <form method="POST" action="/submit">
      <label>
        Name:
        <input type="text" name="name" value="${data.name}" />
      </label>
      <label>
        Email:
        <input type="email" name="email" value="${data.email}" />
      </label>
      <button type="submit">Submit</button>
    </form>
  `
}

let formHtml = renderForm({
  name: 'John "The Boss" Doe',
  email: '[email protected]',
})
// Quotes in name are safely escaped

Dynamic Lists

Render lists from arrays:
import { html } from 'remix/html-template'

interface Product {
  id: number
  name: string
  price: number
}

function renderProducts(products: Product[]) {
  return html`
    <div class="products">
      ${products.map(
        (product) => html`
          <div class="product">
            <h3>${product.name}</h3>
            <p>$${product.price.toFixed(2)}</p>
            <button data-id="${product.id}">Add to Cart</button>
          </div>
        `
      )}
    </div>
  `
}

let productList = renderProducts([
  { id: 1, name: 'Widget <Premium>', price: 19.99 },
  { id: 2, name: 'Gadget "Pro"', price: 29.99 },
])
// Special characters in names are safely escaped

Error Messages

Display user errors safely:
import { html } from 'remix/html-template'

function renderError(message: string) {
  return html`
    <div class="error" role="alert">
      <strong>Error:</strong> ${message}
    </div>
  `
}

// Even if message contains HTML, it will be escaped
let error = renderError('<script>alert("xss")</script>')

API Reference

html

Tagged template literal for creating safe HTML.
function html(strings: TemplateStringsArray, ...values: any[]): SafeHtml
All interpolated values are automatically HTML-escaped unless they are already SafeHtml instances. Example:
let safe = html`<p>Hello ${userInput}</p>`

html.raw

Tagged template literal for creating raw HTML without escaping.
function html.raw(strings: TemplateStringsArray, ...values: any[]): SafeHtml
Only use with trusted content. Never use with user input.
Example:
let trustedSvg = '<svg>...</svg>'
let raw = html.raw`<div>${trustedSvg}</div>`

SafeHtml

A branded type representing safely escaped HTML. Methods:
  • toString(): string - Convert to string (automatically called when coerced)

isSafeHtml(value)

Type guard to check if a value is a SafeHtml instance.
function isSafeHtml(value: any): value is SafeHtml
Example:
let value = html`<p>Test</p>`
if (isSafeHtml(value)) {
  // value is SafeHtml
}

Security Considerations

Default Escaping

All interpolated values are escaped by default:
let userInput = '<script>alert("XSS")</script>'
let safe = html`<div>${userInput}</div>`
// Result: <div>&lt;script&gt;alert("XSS")&lt;/script&gt;</div>

Never Use html.raw with User Input

// DON'T DO THIS
let userInput = '<script>alert("XSS")</script>'
let unsafe = html.raw`<div>${userInput}</div>`

// DO THIS INSTEAD
let safe = html`<div>${userInput}</div>`

Composing SafeHtml is Safe

let userContent = html`<p>${userInput}</p>` // Escaped
let page = html`<div>${userContent}</div>` // Not double-escaped
  • response/html - Create HTML responses with proper Content-Type
  • fetch-router - HTTP router that works great with html-template

Build docs developers (and LLMs) love