Skip to content

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)

CodeNameMeaning
200OKThe request succeeded. This is the default.
201CreatedA new resource was created (e.g. after a POST).
204No ContentSuccess, but there is no body to return.

Redirect Codes (3xx)

CodeNameMeaning
301Moved PermanentlyThe URL has permanently changed. Browsers cache this.
302FoundTemporary redirect. The most common redirect.
303See OtherRedirect after POST (forces a GET request).
307Temporary RedirectLike 302 but preserves the HTTP method.

Client Error Codes (4xx)

CodeNameMeaning
400Bad RequestThe request is malformed or invalid.
401UnauthorizedAuthentication is required but was not provided.
403ForbiddenAuthenticated, but not allowed to access this resource.
404Not FoundThe requested resource does not exist.
422Unprocessable EntityThe request is well-formed but contains validation errors.
429Too Many RequestsRate limit exceeded.

Server Error Codes (5xx)

CodeNameMeaning
500Internal Server ErrorSomething went wrong on the server.
502Bad GatewayThe server, acting as a gateway, got an invalid response from upstream.
503Service UnavailableThe 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

ts
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

ts
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

ts
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

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

ts
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

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

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

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

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

ts
let router = createRouter({
  middleware: [
    errorBoundary(),  // Catch errors from all downstream middleware and handlers
    logger(),
    compression(),
    // ...
  ],
})

Or apply it to specific route groups:

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

ts
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

tsx
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:

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

ts
import { route } from 'remix/fetch-router/routes'

export const routes = route({
  // ... your other routes ...

  // Catch-all route (must be last)
  notFound: '/*path',
})
ts
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:

tsx
// 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:

  1. Handler level --- Check for expected errors (not found, forbidden, invalid input). Return appropriate status codes and user-friendly pages.

  2. Middleware level --- Use an error boundary middleware to catch unexpected errors from handlers. Log them and return a generic error page.

  3. Server level --- The createRequestListener catch 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:

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

Released under the MIT License.