Headers Tutorial
This tutorial walks through the most common header operations: content negotiation, cache control, range requests, and cookie management. Each section shows you how to parse incoming request headers and build outgoing response headers using typed classes.
Prerequisites
- A Remix V3 project with routes that handle HTTP requests
Step 1: Content Negotiation with Accept
Content negotiation lets the server return different representations (HTML, JSON, XML) of the same resource based on what the client prefers.
import { Accept } from 'remix/headers'
export async function loader({ request }: { request: Request }) {
let accept = Accept.parse(request.headers.get('Accept') ?? '*/*')
// Check what the client prefers from the formats you support
let best = accept.negotiate(['text/html', 'application/json'])
if (best === 'application/json') {
return Response.json({ users: [{ name: 'Alice' }] })
}
// Default to HTML
return new Response('<h1>Users</h1><ul><li>Alice</li></ul>', {
headers: { 'Content-Type': 'text/html' },
})
}What is negotiate? It compares the client's Accept header against a list of content types you support and returns the best match, taking quality values into account. If the client sends Accept: application/json;q=0.9, text/html, then negotiate(['text/html', 'application/json']) returns 'text/html' because it has a higher implicit quality of 1.0.
Step 2: Language Negotiation
Serve content in the user's preferred language.
import { AcceptLanguage } from 'remix/headers'
export async function loader({ request }: { request: Request }) {
let lang = AcceptLanguage.parse(
request.headers.get('Accept-Language') ?? 'en',
)
let locale = lang.negotiate(['en', 'fr', 'de', 'ja']) ?? 'en'
let greetings: Record<string, string> = {
en: 'Hello',
fr: 'Bonjour',
de: 'Hallo',
ja: 'こんにちは',
}
return new Response(greetings[locale], {
headers: {
'Content-Language': locale,
'Vary': 'Accept-Language',
},
})
}Step 3: Set Cache-Control Headers
Control how browsers and CDNs cache your responses.
import { CacheControl } from 'remix/headers'
export async function loader() {
let data = await fetchExpensiveData()
// Cache publicly for 1 hour, allow stale-while-revalidate for 1 minute
let cache = new CacheControl({
public: true,
maxAge: 3600,
staleWhileRevalidate: 60,
})
return Response.json(data, {
headers: {
'Cache-Control': cache.stringify(),
// 'public, max-age=3600, stale-while-revalidate=60'
},
})
}Parse Incoming Cache-Control
Inspect caching directives from incoming requests (useful in middleware or proxy logic).
import { CacheControl } from 'remix/headers'
let cache = CacheControl.parse(request.headers.get('Cache-Control') ?? '')
if (cache.noCache) {
// Client wants a fresh response -- bypass the cache
}
if (cache.maxAge !== undefined && cache.maxAge < 60) {
// Client wants very fresh data
}Step 4: Handle Range Requests
Range requests allow clients to download parts of a file. This is how video players seek to a specific time and how download managers resume interrupted transfers.
import { Range, ContentRange } from 'remix/headers'
import { openLazyFile } from 'remix/fs'
export async function loader({ request }: { request: Request }) {
let file = await openLazyFile('./public/videos/intro.mp4')
let rangeHeader = request.headers.get('Range')
if (!rangeHeader) {
// Full file response
return new Response(file.stream(), {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(file.size),
'Accept-Ranges': 'bytes',
},
})
}
// Parse the Range header
let range = Range.parse(rangeHeader)
let { start } = range.ranges[0]
let end = range.ranges[0].end ?? file.size - 1
// Clamp to file bounds
if (start >= file.size) {
return new Response(null, {
status: 416, // Range Not Satisfiable
headers: { 'Content-Range': `bytes */${file.size}` },
})
}
let slice = file.slice(start, end + 1)
return new Response(slice.stream(), {
status: 206, // Partial Content
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(slice.size),
'Content-Range': `bytes ${start}-${end}/${file.size}`,
'Accept-Ranges': 'bytes',
},
})
}Step 5: Set Cookies
Build Set-Cookie headers with typed options instead of string concatenation.
import { SetCookie } from 'remix/headers'
export async function action({ request }: { request: Request }) {
// Create a session cookie
let sessionCookie = new SetCookie('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 1 week
})
// Create a preferences cookie
let prefsCookie = new SetCookie('theme', 'dark', {
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
return new Response(null, {
status: 303,
headers: [
['Set-Cookie', sessionCookie.stringify()],
['Set-Cookie', prefsCookie.stringify()],
['Location', '/dashboard'],
],
})
}Why an array of headers? HTTP allows multiple Set-Cookie headers in a single response. The Headers constructor does not support duplicate keys, but the array form of ResponseInit.headers does.
Step 6: Parse Incoming Cookies
Read cookies from the request using the Cookie class.
import { Cookie } from 'remix/headers'
export async function loader({ request }: { request: Request }) {
let cookies = Cookie.parse(request.headers.get('Cookie') ?? '')
let session = cookies.get('session')
let theme = cookies.get('theme') ?? 'light'
if (!session) {
return new Response(null, {
status: 302,
headers: { Location: '/login' },
})
}
return Response.json({ theme })
}Step 7: Use ContentType for Correct Headers
Build Content-Type headers with proper charset parameters.
import { ContentType } from 'remix/headers'
// Parse
let ct = ContentType.parse('text/html; charset=utf-8')
console.log(ct.type) // 'text/html'
console.log(ct.charset) // 'utf-8'
// Build
let jsonType = new ContentType('application/json', { charset: 'utf-8' })
console.log(jsonType.stringify()) // 'application/json; charset=utf-8'Step 8: Control Caching with Vary
The Vary header tells caches which request headers affect the response, so the cache can store separate versions for different clients.
import { Vary } from 'remix/headers'
export async function loader({ request }: { request: Request }) {
// Build a Vary header
let vary = new Vary(['Accept', 'Accept-Language', 'Accept-Encoding'])
return new Response('content', {
headers: {
'Vary': vary.stringify(), // 'Accept, Accept-Language, Accept-Encoding'
'Cache-Control': 'public, max-age=3600',
},
})
}Summary
| Concept | What You Learned |
|---|---|
| Content negotiation | Use Accept.parse and negotiate to pick the best format |
| Language negotiation | Use AcceptLanguage to serve localized content |
| Cache control | Build Cache-Control with typed directives |
| Range requests | Parse Range, respond with 206 and Content-Range |
| Cookies | Use SetCookie to build and Cookie to parse |
| Content type | Use ContentType for structured type and charset |
| Vary | Tell caches which request headers matter |
Next Steps
- Use response helpers that set these headers automatically
- See the API Reference for all 14 header classes