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.