Skip to content

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.

ts
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.

ts
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.

ts
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).

ts
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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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

ConceptWhat You Learned
Content negotiationUse Accept.parse and negotiate to pick the best format
Language negotiationUse AcceptLanguage to serve localized content
Cache controlBuild Cache-Control with typed directives
Range requestsParse Range, respond with 206 and Content-Range
CookiesUse SetCookie to build and Cookie to parse
Content typeUse ContentType for structured type and charset
VaryTell caches which request headers matter

Next Steps

Released under the MIT License.