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
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 <script>alert("XSS")</script>!</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)
}
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><script>alert("XSS")</script></div>
// 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