Skip to content

Response Tutorial

This tutorial walks through the response helpers for common HTTP response patterns: serving HTML, serving files with caching and range support, redirecting, and compressing.

Prerequisites

  • A Remix V3 project with routes

Step 1: Create HTML Responses

Use createHtmlResponse to return HTML with the correct content type and doctype.

ts
import { createHtmlResponse } from 'remix/response/html'

export async function loader() {
  let html = `
    <html>
      <head><title>My Page</title></head>
      <body><h1>Hello, world!</h1></body>
    </html>
  `

  return createHtmlResponse(html)
  // Status: 200
  // Content-Type: text/html; charset=utf-8
  // Body: <!DOCTYPE html><html>...
}

Custom Status Codes

Pass a ResponseInit to set a custom status or additional headers.

ts
import { createHtmlResponse } from 'remix/response/html'

export async function loader() {
  return createHtmlResponse('<html><body><h1>Not Found</h1></body></html>', {
    status: 404,
    headers: {
      'Cache-Control': 'no-store',
    },
  })
}

Step 2: Serve Files with Automatic Headers

createFileResponse takes a File (or LazyFile) and returns a response with the correct Content-Type, Content-Length, and ETag headers.

ts
import { createFileResponse } from 'remix/response/file'
import { openLazyFile } from 'remix/fs'

export async function loader() {
  let file = await openLazyFile('./public/downloads/manual.pdf')
  return createFileResponse(file)
  // Content-Type: application/pdf
  // Content-Length: 1048576
  // ETag: "..."
  // Accept-Ranges: bytes
}

What is an ETag? An ETag (entity tag) is a fingerprint of the file's content. When a browser requests the same file again, it sends the ETag in an If-None-Match header. If the file has not changed, the server responds with 304 Not Modified, saving bandwidth.

Step 3: Handle Range Requests

createFileResponse automatically supports range requests when you pass the original Request object. This enables video seeking and resumable downloads.

ts
import { createFileResponse } from 'remix/response/file'
import { openLazyFile } from 'remix/fs'

export async function loader({ request }: { request: Request }) {
  let file = await openLazyFile('./public/videos/intro.mp4')

  // Pass the request so createFileResponse can check for Range and If-None-Match headers
  return createFileResponse(file, request)
}

When the client sends a Range: bytes=0-1023 header, the response automatically becomes:

  • Status: 206 Partial Content
  • Content-Range: bytes 0-1023/52428800
  • Body: only the first 1024 bytes

When the client sends If-None-Match with a matching ETag, the response is:

  • Status: 304 Not Modified
  • No body

Step 4: Redirect

Use createRedirectResponse to send clients to a different URL.

ts
import { createRedirectResponse } from 'remix/response/redirect'

export async function action({ request }: { request: Request }) {
  let formData = await request.formData()
  let email = formData.get('email') as string

  // Save the subscription...

  // 303 See Other -- redirect after form submission
  return createRedirectResponse('/thank-you', 303)
}

Redirect Status Codes

StatusWhen to Use
301Permanent redirect. The URL has moved forever.
302Temporary redirect (default). The URL may change.
303See Other. Always redirects with GET. Use after form POST.
307Temporary redirect. Preserves the HTTP method.
308Permanent redirect. Preserves the HTTP method.
ts
// Permanent redirect (SEO)
createRedirectResponse('/new-page', 301)

// Temporary redirect (default)
createRedirectResponse('/maintenance')

// After form submission (always GET)
createRedirectResponse('/success', 303)

Step 5: Compress Responses

Use compressResponse to compress a response body with gzip or brotli based on the client's Accept-Encoding header.

ts
import { compressResponse } from 'remix/response/compress'

export async function loader({ request }: { request: Request }) {
  let data = await fetchLargeDataset()
  let response = Response.json(data)

  // Compress if the client supports it
  return compressResponse(response, request)
}

The function checks the Accept-Encoding request header and picks the best supported encoding. If the client does not support compression, the original response is returned unchanged.

Step 6: Build a File Download Route

Combine the helpers to build a complete file download route with caching, range support, and content disposition.

ts
import { createFileResponse } from 'remix/response/file'
import { openLazyFile } from 'remix/fs'

export async function loader({
  request,
  params,
}: {
  request: Request
  params: { filename: string }
}) {
  let path = `./uploads/${params.filename}`

  let file: File
  try {
    file = await openLazyFile(path)
  } catch {
    return new Response('File not found', { status: 404 })
  }

  let response = createFileResponse(file, request)

  // Add download header so the browser downloads instead of displaying
  response.headers.set(
    'Content-Disposition',
    `attachment; filename="${file.name}"`,
  )

  // Cache for 1 hour
  response.headers.set('Cache-Control', 'public, max-age=3600')

  return response
}

Summary

ConceptWhat You Learned
HTML responsescreateHtmlResponse sets doctype and content type
File responsescreateFileResponse sets Content-Type, ETag, and handles ranges
ETagsAutomatic 304 Not Modified when the file has not changed
Range requestsAutomatic 206 Partial Content for byte-range requests
RedirectscreateRedirectResponse with appropriate status codes
CompressioncompressResponse picks the best encoding from Accept-Encoding

Next Steps

Released under the MIT License.