Error Handling
Things go wrong. Users visit pages that do not exist, submit invalid forms, and encounter server bugs. Good error handling means your application responds gracefully to all of these situations instead of showing a blank page or a confusing stack trace.
This guide covers error handling at every level --- from individual handler errors to application-wide error boundaries.
HTTP Status Codes
Every HTTP response includes a status code --- a three-digit number that tells the client what happened. Understanding the most common ones is essential for building web applications.
Success Codes (2xx)
| Code | Name | Meaning |
|---|---|---|
| 200 | OK | The request succeeded. This is the default. |
| 201 | Created | A new resource was created (e.g. after a POST). |
| 204 | No Content | Success, but there is no body to return. |
Redirect Codes (3xx)
| Code | Name | Meaning |
|---|---|---|
| 301 | Moved Permanently | The URL has permanently changed. Browsers cache this. |
| 302 | Found | Temporary redirect. The most common redirect. |
| 303 | See Other | Redirect after POST (forces a GET request). |
| 307 | Temporary Redirect | Like 302 but preserves the HTTP method. |
Client Error Codes (4xx)
| Code | Name | Meaning |
|---|---|---|
| 400 | Bad Request | The request is malformed or invalid. |
| 401 | Unauthorized | Authentication is required but was not provided. |
| 403 | Forbidden | Authenticated, but not allowed to access this resource. |
| 404 | Not Found | The requested resource does not exist. |
| 422 | Unprocessable Entity | The request is well-formed but contains validation errors. |
| 429 | Too Many Requests | Rate limit exceeded. |
Server Error Codes (5xx)
| Code | Name | Meaning |
|---|---|---|
| 500 | Internal Server Error | Something went wrong on the server. |
| 502 | Bad Gateway | The server, acting as a gateway, got an invalid response from upstream. |
| 503 | Service Unavailable | The server is temporarily unable to handle requests. |
4xx vs 5xx
4xx errors are the client's fault (bad URL, missing auth, invalid data). 5xx errors are the server's fault (bugs, database outages). Your application should never intentionally return a 5xx --- they indicate something unexpected happened.
Returning Error Responses from Handlers
The simplest way to handle errors is to return an appropriate response:
404 Not Found
async show({ get, params }) {
let db = get(Database)
let book = await db.findOne(books, { where: { slug: params.slug } })
if (!book) {
return render(<NotFoundPage />, { status: 404 })
}
return render(<BookPage book={book} />)
}401 Unauthorized
handler({ get }) {
let auth = get(Auth)
if (!auth.ok) {
return createRedirectResponse('/login')
// Or return a 401 response:
// return new Response('Please log in', { status: 401 })
}
return render(<DashboardPage user={auth.identity} />)
}403 Forbidden
handler({ get }) {
let auth = get(Auth)
if (!auth.ok) {
return createRedirectResponse('/login')
}
if (auth.identity.role !== 'admin') {
return render(<ForbiddenPage />, { status: 403 })
}
return render(<AdminPage />)
}400 Bad Request
handler({ request }) {
let body
try {
body = await request.json()
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 },
)
}
// ... process body ...
}Try/Catch in Handlers
When calling external services or performing operations that might fail, use try/catch:
async handler({ get, params }) {
let db = get(Database)
try {
let book = await db.findOne(books, { where: { id: Number(params.id) } })
if (!book) {
return render(<NotFoundPage />, { status: 404 })
}
return render(<BookPage book={book} />)
} catch (error) {
console.error('Database error:', error)
return render(<ErrorPage message="Unable to load book data" />, {
status: 500,
})
}
}Handling Specific Error Types
try {
await db.create(users, { email, password_hash, name })
} catch (error) {
// Handle unique constraint violation (duplicate email)
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return render(
<RegisterForm errors={{ email: 'An account with this email already exists' }} />,
{ status: 422 },
)
}
// Re-throw unexpected errors
throw error
}Global Error Handling in createRequestListener
The createRequestListener function is where you set up application-wide error handling. This catches any unhandled errors from your router:
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'
import { router } from './app/router.ts'
let server = http.createServer(
createRequestListener(async (request) => {
try {
return await router.fetch(request)
} catch (error) {
console.error('Unhandled error:', error)
// Return a generic error page
return new Response(
`<!DOCTYPE html>
<html>
<head><title>Server Error</title></head>
<body>
<h1>Something went wrong</h1>
<p>We are working to fix the problem. Please try again later.</p>
</body>
</html>`,
{
status: 500,
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
},
)
}
}),
)This is your last line of defense. If a route handler throws an unhandled error, this catch block prevents the user from seeing a blank page or a raw error stack trace.
Never show error details in production
In production, show a friendly error message. Never expose error stack traces, database queries, or internal details to users --- this is a security risk.
if (process.env.NODE_ENV === 'development') {
// Show the full error in development
return new Response(`<pre>${error.stack}</pre>`, {
status: 500,
headers: { 'Content-Type': 'text/html' },
})
} else {
// Show a generic message in production
return new Response('Internal Server Error', { status: 500 })
}Error Handling Middleware
You can also handle errors at the middleware level, which is useful for adding error handling to a group of routes:
import type { Middleware } from 'remix/fetch-router'
export function errorBoundary(): Middleware {
return async (context, next) => {
try {
return await next()
} catch (error) {
console.error('Error in route handler:', error)
// You can render a component here
let html = renderToString(<ErrorPage />)
return new Response(html, {
status: 500,
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})
}
}
}Add it to your global middleware stack:
let router = createRouter({
middleware: [
errorBoundary(), // Catch errors from all downstream middleware and handlers
logger(),
compression(),
// ...
],
})Or apply it to specific route groups:
router.map(routes.admin, {
middleware: [errorBoundary()],
actions: { /* ... */ },
})Validation Errors and Form Re-Display
Validation errors are not unexpected failures --- they are a normal part of form handling. Handle them with parseSafe from data-schema:
import { parseSafe } from 'remix/data-schema'
async action({ get }) {
let data = get(FormData)
let values = Object.fromEntries(data) as Record<string, string>
let result = parseSafe(BookSchema, values)
if (!result.success) {
// Build an error map
let errors: Record<string, string> = {}
for (let issue of result.issues) {
let key = issue.path?.[0]?.key
if (typeof key === 'string') {
errors[key] = issue.message
}
}
// Re-render the form with errors and previous values
return render(
<BookForm errors={errors} values={values} />,
{ status: 422 },
)
}
// Validation passed, proceed...
}Use 422 for validation errors
Status 422 (Unprocessable Entity) is the correct code for requests that are syntactically valid but semantically invalid (e.g. a form with missing required fields). It tells the client "I understood your request, but the data is wrong."
Displaying Errors in Forms
function BookForm() {
return ({ errors, values }: {
errors?: Record<string, string>
values?: Record<string, string>
}) => (
<form method="POST">
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
name="title"
value={values?.title ?? ''}
class={errors?.title ? 'input-error' : ''}
/>
{errors?.title ? (
<p class="error-message">{errors.title}</p>
) : null}
</div>
{/* ... more fields ... */}
<button type="submit">Save</button>
</form>
)
}404 Handling for Missing Routes
When no route matches the requested URL, the router does not call any handler. You need to handle this at the server level:
let server = http.createServer(
createRequestListener(async (request) => {
try {
let response = await router.fetch(request)
return response
} catch (error) {
// Check if it's a "no route matched" situation
// The router returns a 404 response automatically for unmatched routes
console.error(error)
return new Response('Internal Server Error', { status: 500 })
}
}),
)The Remix router automatically returns a 404 response when no route matches. You can customize this with a catch-all route:
import { route } from 'remix/fetch-router/routes'
export const routes = route({
// ... your other routes ...
// Catch-all route (must be last)
notFound: '/*path',
})router.map(routes.notFound, {
handler() {
return render(<NotFoundPage />, { status: 404 })
},
})The wildcard /*path matches any URL that was not matched by a more specific route.
Custom Error Pages
Create dedicated components for different error types:
// app/ui/errors/not-found.tsx
function NotFoundPage() {
return () => (
<Layout title="Page Not Found">
<div class="error-page">
<h1>404 --- Page Not Found</h1>
<p>
The page you are looking for does not exist.
It may have been moved or deleted.
</p>
<a href="/" class="btn">Go to Home Page</a>
</div>
</Layout>
)
}
// app/ui/errors/forbidden.tsx
function ForbiddenPage() {
return () => (
<Layout title="Access Denied">
<div class="error-page">
<h1>403 --- Access Denied</h1>
<p>You do not have permission to view this page.</p>
<a href="/" class="btn">Go to Home Page</a>
</div>
</Layout>
)
}
// app/ui/errors/server-error.tsx
function ServerErrorPage() {
return ({ message }: { message?: string }) => (
<Layout title="Server Error">
<div class="error-page">
<h1>500 --- Server Error</h1>
<p>{message ?? 'Something went wrong. Please try again later.'}</p>
<a href="/" class="btn">Go to Home Page</a>
</div>
</Layout>
)
}Error Handling Strategy Summary
Here is the recommended layered approach:
Handler level --- Check for expected errors (not found, forbidden, invalid input). Return appropriate status codes and user-friendly pages.
Middleware level --- Use an error boundary middleware to catch unexpected errors from handlers. Log them and return a generic error page.
Server level --- The
createRequestListenercatch block is the last resort. It handles errors that escape the middleware chain (which should be rare).
Request
|
v
[Server catch] <-- Last resort
|
v
[Error boundary middleware] <-- Catches handler errors
|
v
[Other middleware]
|
v
[Route handler] <-- First line of defense
| |
| (success) | (expected error)
v v
Response Error response (4xx)Logging Errors
Always log errors on the server so you can investigate them later:
// Simple console logging
console.error('Error:', error)
// Structured logging with context
console.error({
message: 'Handler error',
url: context.url.pathname,
method: context.request.method,
error: error.message,
stack: error.stack,
})For production applications, consider sending errors to a monitoring service (Sentry, Datadog, etc.) in addition to logging them.
Related
- Request & Response --- HTTP status codes and response creation.
- Forms & Mutations --- Validation errors and form re-display.
- Middleware --- Error handling middleware patterns.
- Sessions & Cookies --- Flash messages for error notifications.