If your team ships multiple Next.js apps in a monorepo, a consistent error-handling strategy saves countless hours. This post shows how to implement error boundaries the right way with the App Router, share them across packages, and avoid common pitfalls.
What we’ll cover:
- When to use Next.js segment error boundaries vs React client error boundaries
- Monorepo-friendly structure for shared error UI and logging
- Global and route-scoped error handling
- Safe logging from client boundaries via an API route
- Testing and gotchas
Prereqs: Next.js 13.4+ (App Router), TypeScript, Turborepo or similar monorepo tooling.
- The mental model: where errors land
- Server Components and loaders (e.g., data fetching in Server Components, route handlers) can throw. Nearest app/error.tsx in that route segment catches it. If none exists, app/global-error.tsx catches.
- Client Components can throw during render/effects. Next’s segment error.tsx will catch, but you may want a smaller, component-level React Error Boundary to keep the rest of the page interactive.
- 404s are not “errors.” Use not-found.tsx (or the notFound() helper) for missing resources, not error.tsx.
- Monorepo layout (example)
.
├─ apps/
│ ├─ web/ # Next.js (App Router)
│ │ └─ app/
│ │ ├─ global-error.tsx
│ │ ├─ (marketing)/
│ │ │ └─ pricing/
│ │ │ ├─ page.tsx
│ │ │ └─ error.tsx
│ │ └─ api/
│ │ └─ log-error/route.ts
│ └─ admin/ # Another Next.js app reusing shared packages
├─ packages/
│ ├─ errors/ # Shared client ErrorBoundary + error utils
│ ├─ logging/ # Shared server logger + types
│ └─ ui/ # Shared components
└─ turbo.json
- Global error boundary (app/global-error.tsx)
- Catches everything that isn’t handled by a closer segment error.tsx
- Must be a Client Component
"use client";
import React, { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optional: send to server log endpoint. Avoid PII.
fetch("/api/log-error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope: "global", message: error.message, digest: error.digest }),
}).catch(() => {});
}, [error]);
return (
<html>
<body>
<div style={{ padding: 24 }}>
<h1>Something went wrong</h1>
<p>Our team has been notified. Try again.</p>
<button onClick={() => reset()}>Retry</button>
</div>
</body>
</html>
);
}
- Route-segment boundary (app/(marketing)/pricing/error.tsx)
- Scoped to errors thrown in pricing/
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div style={{ padding: 16, border: "1px solid #faa" }}>
<h2>Pricing failed to load</h2>
<pre style={{ whiteSpace: "pre-wrap" }}>{error.message}</pre>
<button onClick={() => reset()}>Retry</button>
</div>
);
}
Tip: Prefer throwing-and-letting-it-bubble in Server Components over defensive null UI everywhere. Place error.tsx at meaningful boundaries (per feature or layout).
- Component-level client Error Boundary (shared) Sometimes you want to isolate a fragile widget inside an otherwise healthy page. For that, provide a shared React Error Boundary from packages/errors.
packages/errors/src/ClientErrorBoundary.tsx
"use client";
import React from "react";
export type ClientErrorBoundaryProps = {
children: React.ReactNode;
fallback?: React.ReactNode;
onError?: (error: Error, info: React.ErrorInfo) => void;
};
export function DefaultFallback() {
return (
<div style={{ padding: 8, border: "1px dashed #888" }}>
Something went wrong in this widget. <button onClick={() => location.reload()}>Reload</button>
</div>
);
}
export class ClientErrorBoundary extends React.Component<
ClientErrorBoundaryProps,
{ hasError: boolean; error?: Error }
> {
state = { hasError: false as const };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, info);
}
render() {
if (this.state.hasError) return this.props.fallback ?? <DefaultFallback />;
return this.props.children;
}
}
Usage in apps/web:
// app/(marketing)/pricing/page.tsx
"use client";
import { ClientErrorBoundary } from "@acme/errors/ClientErrorBoundary";
import { PriceEstimator } from "@acme/ui/PriceEstimator";
export default function PricingPage() {
return (
<div>
<h1>Pricing</h1>
<ClientErrorBoundary>
<PriceEstimator />
</ClientErrorBoundary>
</div>
);
}
- Centralized, safe logging from client boundaries Never import server-only loggers in a Client Component. In a monorepo, keep logging server-side and expose a tiny API route to receive error events.
packages/logging/src/server.ts
export type LogEvent = {
level: "error" | "warn" | "info";
message: string;
scope?: string;
metadata?: Record<string, unknown>;
};
export const serverLogger = {
error(event: LogEvent) {
// Swap with your provider: Sentry/Datadog/NewRelic/etc.
console.error(JSON.stringify({ ts: Date.now(), ...event }));
},
};
apps/web/app/api/log-error/route.ts
import { NextRequest, NextResponse } from "next/server";
import { serverLogger } from "@acme/logging/server";
export async function POST(req: NextRequest) {
try {
const { message, scope, componentStack } = await req.json();
serverLogger.error({
level: "error",
message: String(message || "client_error"),
scope: scope ?? "unknown",
metadata: { componentStack },
});
return NextResponse.json({ ok: true });
} catch (e) {
serverLogger.error({ level: "error", message: "log_error_endpoint_failed" });
return NextResponse.json({ ok: false }, { status: 400 });
}
}
Wire it in your shared ClientErrorBoundary usage:
<ClientErrorBoundary
onError={(error, info) => {
fetch("/api/log-error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "pricing/PriceEstimator",
message: error.message,
componentStack: info.componentStack,
}),
}).catch(() => {});
}}
>
<PriceEstimator />
</ClientErrorBoundary>
- Handling server errors in route handlers and Server Components
- Route handlers (app/api/*/route.ts): handle expected errors and return proper status codes. Throw only for truly unexpected conditions.
import { NextResponse } from "next/server";
export async function GET() {
try {
// ...
return NextResponse.json({ data: [] });
} catch (e) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
- Server Components: prefer throwing on fatal/unexpected errors and let the nearest error.tsx handle UI. For expected domain errors, return a result type and render a friendly state.
- Reuse across multiple apps in the monorepo
- Keep UI and client-only boundaries in packages/ui and packages/errors with "use client".
- Keep logging and server utilities in packages/logging without any "use client". If a package is server-only, add a README and optionally guard imports with the server-only package.
- Provide theme-ability: export a ThemedFallback from packages/errors that accepts brand colors. Apps can pass theme tokens without duplicating logic.
- Testing and CI
- Unit test the client boundary:
import { render, screen } from "@testing-library/react";
import { ClientErrorBoundary } from "@acme/errors/ClientErrorBoundary";
function Boom() {
throw new Error("boom");
}
test("renders fallback on error", () => {
render(
<ClientErrorBoundary>
<Boom />
</ClientErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
- E2E test a segment boundary with Playwright/Cypress: navigate to a page with a forced throw in a loader and assert the error.tsx content.
- Gotchas to avoid
- error.tsx and global-error.tsx are Client Components. Don’t import server-only modules there. Use an API route to log.
- Client Error Boundaries take precedence over segment error.tsx. Use them only to isolate widget-level failures; otherwise let segment boundaries handle it for consistent UX.
- Don’t overuse boundaries. Each boundary is a rerendering unit with reset() semantics; place them on feature seams.
- For forms with Server Actions, handle validation errors as data (return state) instead of throwing. Throw only for unexpected failures.
- If you run some routes at the Edge, confirm your logging transport works in that environment (no Node-only APIs).
- A quick checklist
- [ ] app/global-error.tsx with a retry button
- [ ] error.tsx per critical route segment
- [ ] packages/errors: shared ClientErrorBoundary with optional themed fallback
- [ ] apps/*/app/api/log-error/route.ts wired to packages/logging
- [ ] Unit tests for ClientErrorBoundary and E2E for segment error UIs
- [ ] Lint/CI to prevent server-only imports in client packages
Wrap-up A solid error-boundary strategy in a monorepo balances three layers: route-segment boundaries for feature isolation, a global boundary for last-resort UX, and fine-grained client boundaries for fragile widgets. Centralize logging server-side, never import server utilities into client components, and reuse boundary UI across apps to keep behavior consistent. With these patterns, your Next.js apps will fail gracefully and predictably—at scale.