# How to internationalise error pages in Next.js App Router

**Author:** Jonas Herrmannsdörfer

---

Error pages like `error.tsx` and `not-found.tsx` don't inherit the locale from your `app/[lang]/` pattern. Users see English error messages regardless of their language setting. This guide shows you how to localise both files using the correct patterns for each. ## Why this is tricky Different error files have different constraints: | File            | Component type              | How to access locale | | --------------- | --------------------------- | -------------------- | | `error.tsx`     | Client Component (required) | `useParams()` hook   | | `not-found.tsx` | Server Component            | `headers()` referer  | `error.tsx` [must be a Client Component](https://nextjs.org/docs/app/api-reference/file-conventions/error) because React error boundaries require client-side JavaScript. If your main dictionary loader uses `async/await` and runs only on the server, it won't work in a Client Component.

For `not-found.tsx`, you can use a Server Component, but it doesn't receive the `params` prop directly. You need to extract the locale from the request headers.

## Step 1: Add locale validation helpers

In Next.js 16, dynamic route params are typed as `string` because their values aren't known until runtime. Users can type any URL into the address bar. The [Next.js docs recommend](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes#typescript) using runtime validation to handle invalid params:

`import { notFound } from "next/navigation"; export const i18n = { defaultLocale: "en", locales: ["en", "de", "cs"], } as const; export type Locale = (typeof i18n)["locales"][number]; export function isValidLocale(value: string | undefined): value is Locale { return value !== undefined && i18n.locales.includes(value as Locale); } export function assertValidLocale(value: string): asserts value is Locale { if (!isValidLocale(value)) notFound(); }`

Use `assertValidLocale()` early in your layout and pages. Invalid locales trigger a 404 instead of crashing your app:

`export default async function Root(props: { children: React.ReactNode; params: Promise<{ lang: string }>; }) { const { lang } = await props.params; assertValidLocale(lang); return ( <html lang={lang}> <body>{props.children}</body> </html> ); }`

## Step 2: Create a synchronous error dictionary

Most dictionary loaders are async and server-only. That won't work in a Client Component. Create a separate sync loader with just the error strings:

`const errorDictionaries = { en: { title: "Something went wrong!", "try-again": "Try again" }, de: { title: "Etwas ist schief gelaufen!", "try-again": "Erneut versuchen" }, cs: { title: "Něco se pokazilo!", "try-again": "Zkusit znovu" }, }; export type ErrorDictionary = (typeof errorDictionaries)["en"]; export function getErrorDictionary(locale: string): ErrorDictionary { return ( errorDictionaries[locale as keyof typeof errorDictionaries] ?? errorDictionaries.en ); }`

Keep this dictionary minimal. Error pages should load fast, and since this dictionary is bundled into the client, every byte counts.

## Step 3: Build the localised error page

The `error.tsx` file handles runtime errors. It must be a Client Component:

`"use client"; import { useEffect } from "react"; import { useParams } from "next/navigation"; import { getErrorDictionary } from "../../get-error-dictionary"; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { const params = useParams<{ lang: string }>(); const dictionary = getErrorDictionary(params?.lang ?? "en"); useEffect(() => { console.error(error); }, [error]); return ( <div> <h2>{dictionary.title}</h2> <button onClick={() => reset()}>{dictionary["try-again"]}</button> </div> ); }`

> The `params?.lang ?? "en"` fallback: `useParams()` can return null in error boundaries, so always provide a default.

Skip `assertValidLocale()` here because `error.tsx` is a fallback. The dictionary loader already handles unknown locales by falling back to English.

## Step 4: Add not-found translations to your dictionaries

The `not-found.tsx` file can be a Server Component, so you can use your existing async dictionary loader. Add the `not-found` key to your JSON dictionaries:

`{ ... "not-found": { "title": "Page Not Found", "description": "Could not find the requested resource.", "back-home": "Return Home" } }`

`{ ... "not-found": { "title": "Seite nicht gefunden", "description": "Die angeforderte Ressource konnte nicht gefunden werden.", "back-home": "Zurück zur Startseite" } }`

## Step 5: Build the localised not-found page

First, add a helper function to extract the locale from URLs:

`export function getFirstPathSegment(url: string): string | undefined { try { return new URL(url).pathname.split("/")[1] || undefined; } catch { return undefined; } }`

Then use it in your not-found page to extract the locale from the referer header:

``import Link from "next/link"; import { headers } from "next/headers"; import { getDictionary } from "../../get-dictionary"; import { i18n, isValidLocale, getFirstPathSegment } from "../../i18n-config"; export default async function NotFound() { const headersList = await headers(); const referer = headersList.get("referer") || ""; const urlLocale = getFirstPathSegment(referer); const locale = isValidLocale(urlLocale) ? urlLocale : i18n.defaultLocale; const dictionary = (await getDictionary(locale))["not-found"]; return ( <div> <h2>{dictionary.title}</h2> <p>{dictionary.description}</p> <Link href={`/${locale}`}>{dictionary["back-home"]}</Link> </div> ); }``

The locale detection from the referer header works because the user navigated from a page within your app. If the referer is empty or contains an invalid locale, it falls back to the default.

## Step 6: Add a catch-all route

To trigger your localised 404 for unknown paths within a locale, add a catch-all route:

`import { notFound } from "next/navigation"; export default function CatchAllPage() { notFound(); }`

This ensures `/en/nonexistent` shows your localised not-found page instead of a generic 404. Without this catch-all, Next.js might render a default error page that bypasses your translations.

This catch-all only triggers when no other route matches. If you have routes like `app/[lang]/blog/[slug]/page.tsx`, they match first. The catch-all handles everything else.

## Testing

Verify everything works:

- Visit `/en/nonexistent` → English "Page Not Found"
  
- Visit `/de/nonexistent` → German "Seite nicht gefunden"
  
- Visit `/invalid/anything` → Default locale 404 (via `assertValidLocale`)
  
- Trigger a runtime error → Localised error message with retry button
  

## What about global-error.tsx?

`global-error.tsx` handles errors in the root layout. It completely replaces your layout tree, including `app/[lang]/layout.tsx`. That means it has no access to the locale param.

For most applications, you don't need `global-error.tsx`. The segment-level `error.tsx` inside `app/[lang]/` handles errors within your localised routes, which covers the majority of cases. `global-error.tsx` only triggers when the root layout itself throws an error.

## Quick reference

**Files to create:**

- `app/[lang]/error.tsx` - Client Component with `useParams()`
  
- `app/[lang]/not-found.tsx` - Server Component with `headers()` referer
  
- `app/[lang]/[...slug]/page.tsx` - Catch-all to trigger localised 404
  
- `get-error-dictionary.ts` - Sync dictionary loader for Client Components
  

**Key patterns:**

- Runtime validation with `assertValidLocale()` to narrow `string` to `Locale`
  
- Separate sync dictionary for Client Components
  
- Referer header extraction for locale detection in not-found pages
  
- Null-safe `useParams()` access with fallback

---

[View full KB sitemap](/kb/sitemap.md)
