Utility · Starter

Error Handling

Structured logging, typed custom errors, a React Error Boundary, and API route wrappers — gives every thrown error a consistent shape and HTTP status code so your app fails predictably.

Read the Getting Access guideif you haven't yet.

1. Get the file

Sign in at marrowstack.dev, open the Error Handling block, and click Copy all files. Paste lib/errors.ts (and the React components) into your project. No runtime dependencies — the block uses only TypeScript and React built-ins.

2. Prerequisites

  • Next.js 14 or 15, App Router
  • TypeScript 5+ with strict mode
  • React 18+ (for the Error Boundary component)

3. Install

No additional packages required. The block uses only Next.js and React built-ins.

4. Wire it in

Throw AppError anywhere in your server code — withApi() catches it and maps it to the correct HTTP status automatically:

app/api/items/[id]/route.ts
import { withApi, AppError } from '@/lib/errors'
import { NextRequest } from 'next/server'

export const GET = withApi(async (req: NextRequest, { params }) => {
  const item = await db.items.findUnique({ where: { id: params.id } })

  if (!item) throw new AppError('Item not found', 'NOT_FOUND')
  if (!item.isPublic && !isOwner(item, req)) {
    throw new AppError('Forbidden', 'FORBIDDEN')
  }

  return Response.json(item)
})
Built-in error codes → HTTP status
// AppError maps these codes to HTTP status codes automatically:
// 'NOT_FOUND'            → 404
// 'FORBIDDEN'            → 403
// 'UNAUTHORIZED'         → 401
// 'VALIDATION_ERROR'     → 422
// 'CONFLICT'             → 409
// 'TOO_MANY_REQUESTS'    → 429
// 'INTERNAL_ERROR'       → 500

throw new AppError('Email already in use', 'CONFLICT')
throw new AppError('Invalid input', 'VALIDATION_ERROR', { field: 'email' })
//                                                        ^ extra context, logged but not sent to client
React ErrorBoundary
'use client'
import { ErrorBoundary, ErrorFallback } from '@/lib/errors'

// Wrap any subtree — unhandled render errors show ErrorFallback instead of crashing the page
export function ItemsSection() {
  return (
    <ErrorBoundary fallback={<ErrorFallback message="Could not load items." />}>
      <ItemsList />
    </ErrorBoundary>
  )
}
Structured logging
import { safeLog } from '@/lib/errors'

// Logs to console in dev, structured JSON in production (works with Vercel Log Drains)
safeLog('info',  'Payment captured', { orderId, amount })
safeLog('warn',  'Webhook retry detected', { attempt: 3 })
safeLog('error', 'Supabase insert failed', { error, table: 'ms_users' })

5. Verify it works

  1. Throw new AppError('Test', 'NOT_FOUND') in a route wrapped with withApi() and confirm the response is 404 with a JSON body.
  2. Throw an unhandled Error (not an AppError) and confirm withApi() returns 500 without leaking the stack trace to the client.
  3. Wrap a component that throws during render with ErrorBoundary and confirm the fallback UI is shown.
  4. Call safeLog('error', 'test', { foo: 1 }) and confirm it appears in your terminal / Vercel logs.

6. Failure modes & fixes

withApi() returns 500 for all AppErrors

Cause: The error code is not one of the built-in codes, so it defaults to 500.

Fix: Use one of the defined codes: NOT_FOUND, FORBIDDEN, UNAUTHORIZED, VALIDATION_ERROR, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_ERROR. Or add custom codes to the statusMap in errors.ts.

ErrorBoundary not catching errors

Cause: ErrorBoundary only catches errors during React rendering, not in async event handlers or setTimeout.

Fix: For async errors (e.g. fetch in a click handler), handle them with try/catch and set local error state. ErrorBoundary is for render-phase failures only.

safeLog output missing in Vercel logs

Cause: Log output is buffered and may not flush before the serverless function exits.

Fix: Ensure safeLog is called before returning from the handler. For critical logs, await the log write before responding.