Skip to content
SALIM TECHNOLOGIES

Error handling patterns for Next.js Server Components

Practical patterns we use on every product — error boundaries, typed failures, and never lying to the user.

SALIM Technologies2 min read

Every product we ship runs on Next.js App Router, and every one taught us something about failure. These are the patterns that survived.

1. Model failures as types, not strings

Before anything touches a UI, failures get typed:

type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: AppError };

type AppError =
  | { kind: 'not-found'; resource: string }
  | { kind: 'rate-limited'; retryAfter: number }
  | { kind: 'upstream'; service: string; checked: string[]; skipped: string[] }
  | { kind: 'unknown'; message: string };

The upstream variant comes from OpenLookup, where the cardinal rule is no silent partial results — if a check didn't run, the response must say so. That rule is so useful it leaked into everything else we build.

2. Server Components fail whole, error.tsx catches gracefully

A Server Component that throws takes the page down. Plan for it:

app/
└── products/
    └── [slug]/
        ├── page.tsx
        └── error.tsx    ← the boundary

Two rules for error.tsx:

  • It must be useful without context — the user landed here from a Google result, not from your mental model of the app
  • It must be honest. "Something went wrong" is a waste of everyone's time; "This product couldn't be loaded" plus a retry is respect
'use client';

export default function ProductError({ reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>This product couldn&rsquo;t be loaded.</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

3. notFound() is a render result, not an exception

For missing content, skip try/catch entirely:

export default async function ProductPage({ params }: PageProps) {
  const product = await getProductBySlug(params.slug);
  if (!product) notFound();

  return <ProductView product={product} />;
}

A missing slug is an expected outcome with a designed page (404), not an exceptional one with a stack trace.

4. Validate at the boundary, trust the interior

Content and external data get validated the moment they cross into the system — frontmatter schemas, API responses, form input. Past that boundary, the type system carries the guarantee. Validating "just to be safe" deep in a render path is how components end up defensive, noisy, and slow.

The theme

Notice the theme across all four: never lie to the user. Don't show stale data as fresh, don't show partial results as complete, don't show a spinner for something that already failed. Error handling isn't plumbing — it's the honesty layer of the product.

#nextjs#react#typescript

Related