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.
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.
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.
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.
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.
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
| Status | When to Use |
|---|---|
| 301 | Permanent redirect. The URL has moved forever. |
| 302 | Temporary redirect (default). The URL may change. |
| 303 | See Other. Always redirects with GET. Use after form POST. |
| 307 | Temporary redirect. Preserves the HTTP method. |
| 308 | Permanent redirect. Preserves the HTTP method. |
// 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.
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.
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
| Concept | What You Learned |
|---|---|
| HTML responses | createHtmlResponse sets doctype and content type |
| File responses | createFileResponse sets Content-Type, ETag, and handles ranges |
| ETags | Automatic 304 Not Modified when the file has not changed |
| Range requests | Automatic 206 Partial Content for byte-range requests |
| Redirects | createRedirectResponse with appropriate status codes |
| Compression | compressResponse picks the best encoding from Accept-Encoding |
Next Steps
- Use headers for fine-grained header control
- Open files with fs or file-storage
- See the API Reference for all options