How to Build a multilingual 404 — not found Page in a Next.js 15 Internationalized App
If you’ve tried building an internationalized app with a [locale] dynamic segment in the App Router, you’ve probably discovered something frustrating:
How to Build a multilingual 404 - not found Page in a Next.js 15 Internationalized App

If you've tried building an internationalized app with a [locale] dynamic segment in the App Router, you've probably discovered something frustrating:
Next.js doesn't render your app/[locale]/not-found.tsx by default when users visit non-existent routes inside a locale.
Instead, it often falls back to the root app/not-found.tsx, which means your carefully translated 404 page never shows. After a lot of trial and error, I finally pieced together a working solution , and since I couldn't find a single complete guide online, I'm writing this to save others the same pain.
The Problem
→ You have a folder structure like this:
app/
[locale]/
page.tsx
not-found.tsx
not-found.tsx
Visiting /es/random or /en/random should render the localized 404 (Página no encontrada vs Page not found).
But Next.js routes don't know how to fall into [locale]/not-found.tsx automatically when a path is invalid.
The Complete Solution
The trick is to combine three parts:
- Locale validation in the layout - reject unsupported locales.
- Catch-all route inside [locale] that forces notFound().
- Smart route translation utilities that generate locale-aware links (/es/contacto, /en/contact).
Let's go step by step.
1) Locale Validation in the Layout
In app/[locale]/layout.tsx, we need to explicitly whitelist supported locales and reject everything else.
// app/[locale]/layout.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import "../globals.css";
import { TranslationProvider } from "@/hooks/useTranslation";
import Navbar from "@/components/layout/Navbar";
import Footer from "@/components/layout/Footer";
const LOCALES = ["es", "en"] as const;
type Locale = (typeof LOCALES)[number];
export const dynamicParams = false;
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export default function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const { locale } = params;
if (!LOCALES.includes(locale as Locale)) notFound();
return (
<TranslationProvider locale={locale as Locale}>
<Navbar />
{children}
<Footer />
</TranslationProvider>
);
}This ensures /fr/... or /de/... instantly 404 (in my use-case I work with /en/... and /es/...only).
2) Localized 404 Page
Next.js doesn't automatically render app/[locale]/not-found.tsx unless you explicitly call notFound().
So we add a catch-all route that handles all unmatched paths inside a locale.
// app/[locale]/[...slug]/page.tsx
import { notFound } from "next/navigation";
export default function LocaleCatchAll() {
notFound();
}Now, if a user visits /es/does-not-exist, this page is hit and calls notFound(), which triggers your localized 404 page.
// app/[locale]/not-found.tsx
"use client";
import Link from "next/link";
import { useTranslation } from "@/hooks/useTranslation";
import { getLocalizedRoute } from "@/utils/navigation";
export default function NotFound() {
const { t, language } = useTranslation();
return (
<section className="min-h-[60vh] grid place-items-center px-6 py-20 text-center">
<div className="space-y-6">
<p className="uppercase opacity-60">{t("not-found.kicker")}</p>
<h1 className="text-4xl md:text-6xl font-bold">{t("not-found.title")}</h1>
<p className="opacity-80">{t("not-found.description")}</p>
<div className="flex gap-4 justify-center pt-4">
<Link
href={getLocalizedRoute("/", language)}
className="border px-4 py-2 rounded"
>
{t("not-found.go-home")}
</Link>
<Link
href={getLocalizedRoute("contact", language)}
className="border px-4 py-2 rounded"
>
{t("not-found.contact")}
</Link>
</div>
</div>
</section>
);
}3) Route Translation Utilities
We want /en/contact vs /es/contacto. That requires a mapping system:
// utils/routeTranslations.ts
export const routeTranslations = {
es: {
"about-us": "sobre-nosotros",
services: "servicios",
contact: "contacto",
},
en: {
"about-us": "about-us",
services: "services",
contact: "contact",
},
};
export const routeMapping = {
"sobre-nosotros": "about-us",
servicios: "services",
contacto: "contact",
"about-us": "sobre-nosotros",
services: "servicios",
contact: "contacto",
};
export const locales = ["es", "en"] as const;
export const defaultLocale = "es" as const;
export type Language = (typeof locales)[number];And the helpers:
// utils/navigation.ts
import { routeTranslations, routeMapping, type Language } from "./routeTranslations";
function normalize(path: string) {
return path.replace(/^\/+/, "").replace(/\/+$/, "");
}
export function getLocalizedRoute(route: string, language: Language): string {
if (!route || route === "/") return `/${language}`;
const clean = normalize(route);
const dict = routeTranslations[language] ?? {};
const translated = (dict as Record<string, string>)[clean] ?? clean;
return `/${language}/${translated}`.replace(/\/+/g, "/");
}
export function getRouteForLanguageSwitch(
currentRoute: string,
target: Language,
): string {
const clean = normalize(currentRoute);
const parts = clean.split("/");
const pathParts = parts[0] === "es" || parts[0] === "en" ? parts.slice(1) : parts;
const mapped = pathParts.map((p) => routeMapping[p] ?? p).join("/");
return `/${target}/${mapped}`.replace(/\/+/g, "/");
}4) Translation Provider
Finally, we wrap everything in a provider that exposes both t() and language:
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { defaultLocale, type Language } from "@/utils/routeTranslations";
const TranslationContext = createContext<any>(null);
export function TranslationProvider({
children,
locale,
}: {
children: React.ReactNode;
locale: Language;
}) {
const pathname = usePathname();
const [language, setLanguage] = useState<Language>(locale || defaultLocale);
const [translations, setTranslations] = useState<any>({});
useEffect(() => {
import(`@/locales/${language}.json`).then((mod) => setTranslations(mod.default));
}, [language]);
const t = (key: string) => {
const keys = key.split(".");
return keys.reduce((obj, k) => (obj && obj[k] ? obj[k] : key), translations);
};
return (
<TranslationContext.Provider value={{ t, language }}>
{children}
</TranslationContext.Provider>
);
}
export function useTranslation() {
return useContext(TranslationContext);
}Example in Action
- /es/foobar → renders Spanish 404 with buttons → /es and /es/contacto
- /en/foobar → renders English 404 with buttons → /en and /en/contact
- /fr/foobar → rejected by layout → localized 404
Key Takeaways
- Next.js won't render localized 404s automatically - you must use a [...slug] catch-all that calls notFound().
- Always validate locales in the layout, or users can access unsupported routes.
- A simple route translation system (routeTranslations + routeMapping) makes all your links language-aware.
- Expose language via a provider so every component knows which locale it's in.
This pattern gives you a clean, future-proof 404 system for internationalized apps in Next.js 15. I struggled to find any documentation on this, so hopefully this guide saves you hours of debugging.
Next time you spin up a multilingual project, you'll have a ready-made recipe for a professional 404 page that respects your locales!
Thanks for reading :)
If you liked this article, feel free to connect with me!
Aussi publié sur Medium