TL;DR
This article walks through implementing multi-language support in Next.js 15 using next-intl, with real production code from DevType - a typing practice game for programmers.
Live Demo in 8 Languages:
Why next-intl?
When building a multi-language Next.js application, you have several options. I chose next-intl for these reasons:
| Feature | next-intl | react-i18next | next-translate |
|---|---|---|---|
| App Router Support | First-class | Adapter needed | Limited |
| Server Components | Native | Manual setup | Not supported |
| Type Safety | Excellent | Good | Moderate |
| Bundle Size | Small | Larger | Small |
next-intl is built specifically for Next.js and provides seamless integration with the App Router and Server Components.
Project Structure
Here's the i18n file structure in DevType:
src/
├── app/
│ ├── [locale]/ # Dynamic locale segment
│ │ ├── layout.tsx # Locale-specific layout
│ │ ├── page.tsx # Home page
│ │ ├── play/
│ │ ├── ranking/
│ │ └── ...
│ ├── layout.tsx # Root layout
│ └── sitemap.ts # SEO sitemap
├── i18n/
│ ├── config.ts # Locale configuration
│ ├── routing.ts # Navigation helpers
│ └── request.ts # Server-side message loading
├── middleware.ts # Locale detection & routing
└── messages/
├── en.json
├── ja.json
├── zh.json
└── ...
Step 1: Install next-intl
npm install next-intl
Step 2: Configure Locales
Create a central configuration file for your supported languages:
src/i18n/config.ts
export const locales = ["en", "ja", "zh", "es", "pt", "de", "fr", "it"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
ja: "日本語",
zh: "中文",
es: "Español",
pt: "Português",
de: "Deutsch",
fr: "Français",
it: "Italiano",
};
Key points:
- Use
as constfor type inference - Export a
Localetype for type safety - Store display names for UI language selectors
Step 3: Set Up Routing
Create navigation helpers that work with your locale structure:
src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
import { locales, defaultLocale } from "./config";
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: "always", // URLs always include locale: /en, /ja, etc.
});
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
The localePrefix: "always" option ensures clean, consistent URLs:
-
https://devtype.honualohak.com/en(English) -
https://devtype.honualohak.com/ja(Japanese) -
https://devtype.honualohak.com/zh(Chinese)
Important: Use the exported Link component instead of Next.js's default Link to ensure correct locale handling.
Step 4: Configure Message Loading
Set up server-side message loading:
src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { locales, type Locale } from "./config";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Fallback if locale is invalid
if (!locale || !locales.includes(locale as Locale)) {
locale = "en";
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
Step 5: Configure Next.js
Wrap your Next.js config with the next-intl plugin:
next.config.ts
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
// Your other config options...
};
export default withNextIntl(nextConfig);
Step 6: Set Up Middleware
The middleware handles locale detection, redirects, and can be combined with other middleware (like authentication).
Combining middleware (like Supabase Auth) with next-intl can be tricky because both try to manage the response. The code below demonstrates how to merge cookies correctly so authentication and localization work together:
src/middleware.ts
import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
import { routing } from "@/i18n/routing";
const intlMiddleware = createMiddleware(routing);
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip i18n middleware for auth callbacks
if (pathname.startsWith("/auth/")) {
return await updateSession(request);
}
// Handle Supabase session
const supabaseResponse = await updateSession(request);
// Handle i18n routing
const intlResponse = intlMiddleware(request);
// Merge cookies from Supabase response
supabaseResponse.cookies.getAll().forEach((cookie) => {
intlResponse.cookies.set(cookie.name, cookie.value);
});
return intlResponse;
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
],
};
The middleware automatically:
- Detects the user's preferred language from browser settings
- Redirects to the appropriate locale URL
- Handles locale switching
Step 7: Create the Locale Layout
The [locale] dynamic segment layout provides translations to all child components:
src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, type Locale } from "@/i18n/config";
type Props = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
// Generate static params for all locales (for static generation)
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
// Validate locale
if (!locales.includes(locale as Locale)) {
notFound();
}
// Enable static rendering
setRequestLocale(locale);
// Load messages for this locale
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</NextIntlClientProvider>
);
}
Key points:
-
generateStaticParams()enables static generation for all locales -
setRequestLocale()is required for static rendering -
NextIntlClientProvidermakes translations available to client components
Step 8: Create Translation Files
Create JSON files for each language in the messages/ directory:
messages/en.json
{
"common": {
"appName": "DevType",
"tagline": "Code like a Pro.",
"loading": "Loading...",
"error": "An error occurred"
},
"nav": {
"home": "Home",
"ranking": "Ranking",
"about": "About",
"login": "Login",
"logout": "Logout"
},
"home": {
"hero": {
"title": "Type Code,\nSharpen Skills",
"subtitle": "Practice real-world code in a professional code editor.",
"cta": "Start Now"
}
},
"play": {
"wpm": "WPM",
"accuracy": "Accuracy",
"mistakes": "Mistakes"
}
}
messages/ja.json
{
"common": {
"appName": "DevType",
"tagline": "コードを極めろ。",
"loading": "読み込み中...",
"error": "エラーが発生しました"
},
"nav": {
"home": "ホーム",
"ranking": "ランキング",
"about": "DevTypeについて",
"login": "ログイン",
"logout": "ログアウト"
},
"home": {
"hero": {
"title": "コードを打ち込め、\nスキルを磨け",
"subtitle": "本物のコードエディタで実践的なコードを練習しよう。",
"cta": "今すぐ始める"
}
},
"play": {
"wpm": "WPM",
"accuracy": "正確率",
"mistakes": "ミス"
}
}
Step 9: Use Translations in Components
In Server Components
import { getTranslations } from "next-intl/server";
export default async function HomePage() {
const t = await getTranslations("home");
return (
<section>
<h1>{t("hero.title")}</h1>
<p>{t("hero.subtitle")}</p>
<button>{t("hero.cta")}</button>
</section>
);
}
In Client Components
"use client";
import { useTranslations } from "next-intl";
export function GameStats() {
const t = useTranslations("play");
return (
<div>
<span>{t("wpm")}: 85</span>
<span>{t("accuracy")}: 98%</span>
<span>{t("mistakes")}: 3</span>
</div>
);
}
With Variables
// In JSON:
// "shareText": "I scored {score} points! #DevType"
const t = useTranslations("result");
const message = t("shareText", { score: 8500 });
// Output: "I scored 8500 points! #DevType"
Step 10: SEO - Sitemap with hreflang
For SEO, generate a sitemap with hreflang alternates for all languages:
src/app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/i18n/config";
const APP_URL = "https://devtype.honualohak.com";
const staticPages = ["", "/ranking", "/about", "/faq"];
export default function sitemap(): MetadataRoute.Sitemap {
const sitemap: MetadataRoute.Sitemap = [];
for (const locale of locales) {
for (const page of staticPages) {
const url = `${APP_URL}/${locale}${page}`;
// Generate alternates for all locales
const languages: Record<string, string> = {};
for (const altLocale of locales) {
languages[altLocale] = `${APP_URL}/${altLocale}${page}`;
}
languages["x-default"] = `${APP_URL}/en${page}`;
sitemap.push({
url,
lastModified: new Date(),
changeFrequency: page === "" ? "daily" : "weekly",
priority: page === "" ? 1 : 0.8,
alternates: {
languages,
},
});
}
}
return sitemap;
}
This generates sitemap entries like:
<url>
<loc>https://devtype.honualohak.com/en</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://devtype.honualohak.com/en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://devtype.honualohak.com/ja"/>
<xhtml:link rel="alternate" hreflang="zh" href="https://devtype.honualohak.com/zh"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://devtype.honualohak.com/en"/>
<!-- ... other locales -->
</url>
Common Patterns
Type-Safe Translation Keys
Create a type definition file (e.g., src/types/i18n.d.ts or src/global.d.ts) to catch typos at compile time:
// src/types/i18n.d.ts
// Infer translation keys from your JSON structure
type Messages = typeof import("../../messages/en.json");
declare global {
interface IntlMessages extends Messages {}
}
Locale-Aware Links
Always use the exported Link from routing:
import { Link } from "@/i18n/routing";
// Automatically adds current locale to href
<Link href="/ranking">Rankings</Link>
// Renders: /en/ranking or /ja/ranking based on current locale
Dynamic Locale Switching
"use client";
import { useRouter, usePathname } from "@/i18n/routing";
import { locales, localeNames } from "@/i18n/config";
export function LocaleSwitcher() {
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<select onChange={(e) => switchLocale(e.target.value)}>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
);
}
Performance Tips
Static Generation: Use
generateStaticParams()to pre-render all locale variants at build time.Message Splitting: For large apps, consider splitting messages by page/feature:
messages: {
...(await import(`../../messages/${locale}/common.json`)).default,
...(await import(`../../messages/${locale}/home.json`)).default,
}
- Lazy Loading: Load translations for specific features on demand:
const messages = await import(`@/messages/${locale}/dashboard.json`);
Conclusion
Implementing multi-language support in Next.js 15 with next-intl is straightforward when you follow this structure. The key components are:
-
Centralized config (
src/i18n/config.ts) -
Routing helpers (
src/i18n/routing.ts) - Middleware for automatic locale detection
-
[locale]layout to provide translations - JSON message files for each language
- SEO sitemap with hreflang alternates
Try it yourself at DevType - available in 8 languages!
Resources
What languages does your app support? Share your i18n setup in the comments!
Top comments (0)