In this blog post, I want to talk about the process of optimizing locale handling in a Next.js application. Our initial implementation relied on a cookie-based approach for internationalization, which forced dynamic rendering and reduced both performance and SEO effectiveness.
Migrating to a locale-based routing strategy, where the locale is embedded directly in the URL path (e.g., /en/about
, /ja/about
), enabled Next.js to pre-render locale-specific routes at build time. This restored the benefits of static site generation (SSG), resulting in faster load times and improved SEO.
Leveraging Locale-Based Routing
Embedding the locale directly into the URL path solves these challenges. Each locale-specific path becomes a static route that can be pre-rendered at build time. For instance, /en/about
and /ja/about
are treated as distinct static pages, each with its respective translations.
This approach simplifies navigation logic and enhances SEO. URLs are shareable and bookmarkable with the correct language context, and search engines can index each locale individually, improving discoverability.
We can achieve this cleanly by separating our next-intl
configuration into dedicated files.
1. Locale Configuration (config.ts
)
First, we create a central configuration file to define our locales and their names. This promotes code reusability and makes it easy to add or remove languages in the future.
export type Locale = 'en' | 'ja';
export const locales: Locale[] = ['en', 'ja'] as const;
export const defaultLocale: Locale = 'en';
export const localeNames: Record<Locale, string> = {
en: "EN",
ja: "日本"
} as const;
2. Routing Setup (routing.ts
)
Next, we define our routing strategy. By setting localePrefix: 'never'
, we explicitly tell next-intl
that we are handling the locale prefixes ourselves through the Next.js [locale]
dynamic segment. This prevents next-intl
from adding an extra prefix to our URLs.
import { defineRouting } from 'next-intl/routing';
import { defaultLocale, locales } from './config';
export const routing = defineRouting({
locales: locales,
defaultLocale: defaultLocale,
localePrefix: 'never'
});
Note: The localePrefix
option controls whether the locale appears in the URL path:
-
'never'
→ locale does not appear in the URL (/about
for all languages). -
'always'
→ locale always appears (/en/about
,/ja/about
). -
'as-needed'
→ locale appears only if it’s not the default locale (/about
for default,/ja/about
for Japanese).
Choosing the right option depends on whether URL-based SEO and shareable links for each locale are important.
3. Navigation Handling (navigation.ts
)
We then use our defined routing configuration to create custom navigation helpers. This ensures that every time we use Link
, useRouter
, or other navigation functions, they automatically respect our locale-based routing setup.
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
4. Server-Side Locale Resolution (request.ts
)
The request.ts
file handles the server-side logic for fetching locale-specific data. We use getRequestConfig
to determine the correct locale from the request and load the corresponding message files. The hasLocale
helper function is crucial here to ensure a valid locale is being requested, falling back to the defaultLocale
if not.
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
const messages = (await import(\`@/i18n/locales/\${locale}/common.json\`)).default;
return {
locale,
messages,
};
});
5. Root Layout (layout.ts
)
Our root layout handles the foundational setup for every page. We use setRequestLocale(locale)
to tell next-intl
to use the locale from the URL. This allows Next.js to leverage static rendering because the locale is known at the time of the request. The hasLocale
check also helps us catch invalid locales and serve a notFound()
page.
import "@/index.css";
import { geistSans, geistMono } from "@/constants/fonts";
import { metadata } from "@/constants/metadata";
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation";
import { setRequestLocale } from "next-intl/server";
export { metadata };
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ locale: string }>;
}>) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
6. Client-Side Locale Switcher (locale-switcher.tsx
)
This component provides a user-friendly way to change the language. It uses the custom useRouter
and usePathname
from our navigation.ts file. When the user selects a new locale, the router.push
call automatically updates the URL path to reflect the new language, e.g., from /en/about
to /ja/about
.
"use client";
import { useLocale } from "next-intl";
import { locales, localeNames, type Locale } from "@/i18n/config";
import { usePathname, useRouter } from "@/i18n/navigation";
export default function LocaleSwitcher() {
const locale = useLocale();
const pathname = usePathname() || "/";
const router = useRouter();
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newLocale = event.target.value as Locale;
router.push(pathname, { locale: newLocale });
};
return (
<div className="flex items-center space-x-2" aria-label="Locale switcher">
<label htmlFor="locale-select" className="sr-only">
Select language
</label>
<select
id="locale-select"
value={locale}
onChange={handleChange}
className="border border-gray-300 rounded p-1 text-blue-500 focus:outline-none focus:ring focus:ring-blue-300"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
</div>
);
}
7. Handling Invalid Routes
A catch-all route ensures consistent handling of invalid URLs or unsupported locales:
Implementation
-
Create a Catch-All Route: Create a new folder and a
page.tsx
file at the root of yourapp
directory. The folder should be named[...rest]
to create a catch-all segment. This structure ensures that any request that doesn't match an existing file or folder in your application will be caught by this route.
/app
├── /[locale]
│ └── page.tsx
├── /[...rest]
│ └── page.tsx
└── ...
-
Implement the Catch-All Page: Inside the
app/[...rest]/page.tsx
file, you can now render your custom not-found page. By usingnotFound()
fromnext/navigation
, you signal to Next.js to display your customnot-found.tsx
component, which you should have already defined at the top level of yourapp
directory.
// app/[...rest]/page.tsx
import { notFound } from 'next/navigation';
export default function CatchAllPage() {
notFound();
}
Conclusion
By moving from a cookie-based locale system to URL-based routing, static site generation is fully restored while keeping internationalization seamless and SEO-friendly. The modular structure simplifies adding new locales, ensures consistent routing, and improves overall application performance.
Top comments (0)