Skip to main content
Proper error handling is critical for building resilient applications. Remix provides multiple layers for handling errors at different stages of request processing.

Router-Level Error Handling

Catch errors at the router level to provide consistent error responses:
import { createRouter } from 'remix/fetch-router'

let router = createRouter({
  onError(error, context) {
    console.error('Router error:', error)
    
    if (error instanceof ValidationError) {
      return Response.json(
        { error: error.message, fields: error.fields },
        { status: 400 }
      )
    }

    if (error instanceof NotFoundError) {
      return Response.json(
        { error: 'Resource not found' },
        { status: 404 }
      )
    }

    // Generic error response
    return Response.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  },
})

Middleware Error Handling

Handle errors in middleware:
import type { Middleware } from 'remix/fetch-router'

function errorHandler(): Middleware {
  return async (context, next) => {
    try {
      return await next()
    } catch (error) {
      console.error('Middleware error:', error)

      if (error instanceof SyntaxError) {
        return Response.json(
          { error: 'Invalid JSON in request body' },
          { status: 400 }
        )
      }

      throw error // Re-throw for router-level handler
    }
  }
}

let router = createRouter({
  middleware: [errorHandler()],
})

Action-Level Error Handling

Handle errors in individual actions:
router.post(routes.users, async ({ request }) => {
  try {
    let data = await request.json()
    
    // Validate input
    if (!data.email || !data.name) {
      return Response.json(
        { error: 'Email and name are required' },
        { status: 400 }
      )
    }

    // Create user
    let user = await db.create(users, data)
    
    return Response.json(user, { status: 201 })
  } catch (error) {
    if (error.code === 'UNIQUE_CONSTRAINT') {
      return Response.json(
        { error: 'Email already exists' },
        { status: 409 }
      )
    }
    throw error
  }
})

Custom Error Classes

Create custom error classes for better error handling:
class AppError extends Error {
  constructor(
    message: string,
    public status: number = 500,
    public code?: string
  ) {
    super(message)
    this.name = 'AppError'
  }
}

class ValidationError extends AppError {
  constructor(
    message: string,
    public fields: Record<string, string>
  ) {
    super(message, 400, 'VALIDATION_ERROR')
    this.name = 'ValidationError'
  }
}

class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404, 'NOT_FOUND')
    this.name = 'NotFoundError'
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401, 'UNAUTHORIZED')
    this.name = 'UnauthorizedError'
  }
}
Usage:
router.get(routes.user, async ({ params }) => {
  let user = await db.find(users, { id: params.id })
  
  if (!user) {
    throw new NotFoundError('User')
  }

  return Response.json(user)
})

Validation Errors

Handle validation errors with detailed field information:
import { parse } from 'remix/data-schema'
import { string, object } from 'remix/data-schema'

let userSchema = object({
  name: string().minLength(2).maxLength(50),
  email: string().email(),
  age: number().min(18).max(120),
})

router.post(routes.users, async ({ request }) => {
  let data = await request.json()
  
  let result = parseSafe(userSchema, data)
  
  if (!result.success) {
    let fieldErrors: Record<string, string> = {}
    
    for (let issue of result.issues) {
      if (issue.path) {
        fieldErrors[issue.path.join('.')] = issue.message
      }
    }

    throw new ValidationError('Validation failed', fieldErrors)
  }

  let user = await db.create(users, result.value)
  return Response.json(user, { status: 201 })
})

Database Errors

Handle database errors gracefully:
router.post(routes.users, async ({ request }) => {
  try {
    let data = await request.json()
    let user = await db.create(users, data)
    return Response.json(user, { status: 201 })
  } catch (error: any) {
    // PostgreSQL unique constraint
    if (error.code === '23505') {
      return Response.json(
        { error: 'Email already exists' },
        { status: 409 }
      )
    }

    // Foreign key violation
    if (error.code === '23503') {
      return Response.json(
        { error: 'Referenced resource does not exist' },
        { status: 400 }
      )
    }

    throw error
  }
})

AbortError Handling

Handle aborted requests:
import { AbortError } from 'remix/fetch-router'

router.get(routes.search, async ({ url }) => {
  let query = url.searchParams.get('q')
  
  try {
    let results = await fetchSearchResults(query)
    return Response.json(results)
  } catch (error) {
    if (error instanceof AbortError) {
      // Request was aborted, don't log as error
      return new Response('Request aborted', { status: 499 })
    }
    throw error
  }
})

Error Responses

Return consistent error response formats:
interface ErrorResponse {
  error: string
  code?: string
  fields?: Record<string, string>
  details?: any
}

function errorResponse(
  message: string,
  status: number,
  options?: {
    code?: string
    fields?: Record<string, string>
    details?: any
  }
): Response {
  let body: ErrorResponse = {
    error: message,
    ...options,
  }

  return Response.json(body, { status })
}

// Usage
return errorResponse('User not found', 404, { code: 'NOT_FOUND' })

Error Logging

Log errors for monitoring and debugging:
function logError(error: Error, context: any) {
  console.error('Error:', {
    message: error.message,
    stack: error.stack,
    url: context.url,
    method: context.method,
    timestamp: new Date().toISOString(),
  })

  // Send to error tracking service (e.g., Sentry, Rollbar)
  // errorTracker.captureException(error, { extra: context })
}

let router = createRouter({
  onError(error, context) {
    logError(error, context)
    return errorResponse('Internal server error', 500)
  },
})

Environment-Specific Errors

Provide different error details based on environment:
let isDevelopment = process.env.NODE_ENV === 'development'

let router = createRouter({
  onError(error, context) {
    logError(error, context)

    if (isDevelopment) {
      // Detailed errors in development
      return Response.json(
        {
          error: error.message,
          stack: error.stack,
          context: {
            url: context.url,
            method: context.method,
          },
        },
        { status: 500 }
      )
    }

    // Generic errors in production
    return errorResponse('Internal server error', 500)
  },
})

Best Practices

  • Always handle errors at multiple levels
  • Use custom error classes for clarity
  • Log errors with context for debugging
  • Return consistent error response formats
  • Don’t expose sensitive information in production
  • Use appropriate HTTP status codes
  • Validate input early
  • Handle database errors specifically

Fetch Router

Router error handling options

Data Schema

Input validation with schemas

Build docs developers (and LLMs) love