Skip to main content
Safe HTML template literals for Remix that automatically escape interpolated values to prevent XSS while supporting explicit trusted HTML insertion.

Installation

npm i remix

Features

  • Automatic HTML Escaping - All interpolated values are escaped by default
  • Explicit Raw HTML - Use html.raw for trusted HTML 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

Basic 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>
All interpolated values are automatically escaped to prevent XSS attacks.

API Reference

html

A tagged template function that escapes interpolated values as HTML.
function html(
  strings: TemplateStringsArray,
  ...values: Interpolation[]
): SafeHtml
strings
TemplateStringsArray
required
The template strings from the tagged template literal.
values
Interpolation[]
required
Values to interpolate into the template (automatically escaped).
returns
SafeHtml
A branded string that is safe to render as HTML.

html.raw

A tagged template function that does NOT escape interpolated values. Use only with trusted content.
function html.raw(
  strings: TemplateStringsArray,
  ...values: Interpolation[]
): SafeHtml
Only use html.raw with content you trust. Never use it with user input as it can lead to XSS vulnerabilities.
import { html } from 'remix/html-template'

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

console.log(String(button))
// Output: <button><svg>...</svg> Click me</button>

isSafeHtml

Checks if a value is a SafeHtml string.
function isSafeHtml(value: unknown): value is SafeHtml
value
unknown
required
The value to check.
returns
boolean
true if the value is a SafeHtml string, false otherwise.
import { html, isSafeHtml } from 'remix/html-template'

let safe = html`<div>Safe</div>`
let unsafe = '<div>Unsafe</div>'

isSafeHtml(safe) // true
isSafeHtml(unsafe) // false

Examples

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

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

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

function renderPage(title: string, content: string) {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>${title}</title>
      </head>
      <body>
        <main>${content}</main>
      </body>
    </html>
  `
}

let userContent = '<script>alert("XSS")</script>'
let page = renderPage('My Page', userContent)
// userContent is automatically escaped

Using with Response

Create HTML responses using SafeHtml:
import { html } from 'remix/html-template'
import { createHtmlResponse } from 'remix/response/html'

let page = html`
  <h1>Welcome</h1>
  <p>Hello, ${userName}!</p>
`

let response = createHtmlResponse(page)

Mixing Safe and Raw HTML

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

let userBio = '<script>alert("XSS")</script>'
let adminContent = '<strong>Admin Notice</strong>' // trusted content

let page = html`
  <div class="user-profile">
    <h2>User Bio</h2>
    <div class="bio">${userBio}</div>
    
    <div class="admin-notice">
      ${html.raw`${adminContent}`}
    </div>
  </div>
`
// userBio is escaped, adminContent is not

Type Definitions

type SafeHtml = String & { readonly [kSafeHtml]: true }

type Interpolation =
  | SafeHtml
  | string
  | number
  | boolean
  | null
  | undefined
  | Array<Interpolation>

interface SafeHtmlHelper {
  (strings: TemplateStringsArray, ...values: Interpolation[]): SafeHtml
  raw(strings: TemplateStringsArray, ...values: Interpolation[]): SafeHtml
}

const html: SafeHtmlHelper

function isSafeHtml(value: unknown): value is SafeHtml

Escaping Rules

The following characters are escaped when using html:
CharacterEscaped To
&&amp;
<&lt;
>&gt;
"&quot;
'&#39;

Value Handling

Automatic Escaping (html)

  • Strings: HTML-escaped
  • Numbers/Booleans: Converted to string and escaped
  • SafeHtml: Used as-is (no double-escaping)
  • Arrays: Recursively processed and joined
  • null/undefined: Rendered as empty string

Raw Interpolation (html.raw)

  • Strings: Used as-is (NOT escaped)
  • Numbers/Booleans: Converted to string (not escaped)
  • SafeHtml: Used as-is
  • Arrays: Recursively processed and joined
  • null/undefined: Rendered as empty string

Best Practices

Always Escape User Input

// ✅ Good: User input is automatically escaped
let page = html`<div>${userInput}</div>`

// ❌ Bad: User input is NOT escaped
let page = html.raw`<div>${userInput}</div>`

Use html.raw Only for Trusted Content

// ✅ Good: Using raw for static, trusted HTML
let icon = '<svg><path d="..."/></svg>'
let button = html.raw`<button>${icon} Click</button>`

// ❌ Bad: Using raw with user content
let comment = getUserComment() // Could contain malicious HTML
let post = html.raw`<div>${comment}</div>`

Compose SafeHtml Values

// ✅ Good: Compose using SafeHtml
function userCard(user: User) {
  return html`
    <div class="card">
      <h3>${user.name}</h3>
      <p>${user.bio}</p>
    </div>
  `
}

let cards = users.map((user) => userCard(user))
let page = html`<div class="users">${cards}</div>`

Convert to String When Needed

let page = html`<h1>Hello</h1>`

// Explicit conversion
String(page)

// Implicit conversion (in string contexts)
console.log(page) // Automatically converts to string
response.headers.set('Content-Length', String(page.length))
  • response - Response helpers (includes createHtmlResponse that works with SafeHtml)

Build docs developers (and LLMs) love