Safe HTML template literals for Remix that automatically escape interpolated values to prevent XSS while supporting explicit trusted HTML insertion.
Installation
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 <script>alert("XSS")</script>!</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 to interpolate into the template (automatically escaped).
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
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:
| Character | Escaped To |
|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
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
// ✅ 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)