Full internationalization requires routing, translations, and SEO. Here is how to set it up.
Configuration
// lib/i18n/config.ts
export const locales = ["en", "es", "fr", "de", "ja", "ar"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
es: "Espanol",
fr: "Francais",
de: "Deutsch",
ja: "Japanese",
ar: "Arabic",
};
export const rtlLocales: Locale[] = ["ar"];
export function isRtl(locale: Locale): boolean {
return rtlLocales.includes(locale);
}
Translation Files
// messages/en.json
{
"common": {
"home": "Home",
"about": "About",
"contact": "Contact",
"learnMore": "Learn More"
},
"home": {
"title": "Welcome to Our Site",
"subtitle": "We build digital experiences",
"cta": "Get Started"
},
"contact": {
"title": "Get in Touch",
"name": "Your Name",
"email": "Email Address",
"message": "Message",
"send": "Send Message"
}
}
// messages/es.json
{
"common": {
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto",
"learnMore": "Saber Mas"
},
"home": {
"title": "Bienvenido a Nuestro Sitio",
"subtitle": "Construimos experiencias digitales",
"cta": "Comenzar"
},
"contact": {
"title": "Contactenos",
"name": "Su Nombre",
"email": "Correo Electronico",
"message": "Mensaje",
"send": "Enviar Mensaje"
}
}
Translation Loader
// lib/i18n/translations.ts
import type { Locale } from "./config";
type Messages = Record<string, Record<string, string>>;
const messageCache = new Map<Locale, Messages>();
export async function getMessages(locale: Locale): Promise<Messages> {
if (messageCache.has(locale)) {
return messageCache.get(locale)!;
}
const messages = (await import(`@/messages/${locale}.json`)).default;
messageCache.set(locale, messages);
return messages;
}
export function createTranslator(messages: Messages) {
return function t(key: string, params?: Record<string, string | number>): string {
const [namespace, ...rest] = key.split(".");
const messageKey = rest.join(".");
let message = messages[namespace]?.[messageKey] ?? key;
// Replace parameters: "Hello {name}" -> "Hello World"
if (params) {
for (const [param, value] of Object.entries(params)) {
message = message.replace(`{${param}}`, String(value));
}
}
return message;
};
}
Middleware for Locale Detection
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
function getLocaleFromHeaders(request: NextRequest): Locale {
const acceptLanguage = request.headers.get("accept-language") ?? "";
const preferred = acceptLanguage
.split(",")
.map((lang) => lang.split(";")[0].trim().split("-")[0])
.find((lang) => locales.includes(lang as Locale));
return (preferred as Locale) ?? defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if the path already has a locale
const pathnameLocale = locales.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
if (pathnameLocale) return NextResponse.next();
// Skip static files and API routes
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Check cookie for saved preference
const cookieLocale = request.cookies.get("locale")?.value as Locale | undefined;
const locale = cookieLocale && locales.includes(cookieLocale)
? cookieLocale
: getLocaleFromHeaders(request);
// Redirect to localized path
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)"],
};
Route Structure
app/
[locale]/
layout.tsx
page.tsx
about/
page.tsx
contact/
page.tsx
Localized Layout
// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { locales, isRtl, type Locale } from "@/lib/i18n/config";
import { getMessages, createTranslator } from "@/lib/i18n/translations";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) {
notFound();
}
const messages = await getMessages(locale as Locale);
const t = createTranslator(messages);
return (
<html lang={locale} dir={isRtl(locale as Locale) ? "rtl" : "ltr"}>
<body>
<TranslationProvider messages={messages} locale={locale as Locale}>
<nav className="flex items-center gap-4 p-4 border-b">
<a href={`/${locale}`}>{t("common.home")}</a>
<a href={`/${locale}/about`}>{t("common.about")}</a>
<a href={`/${locale}/contact`}>{t("common.contact")}</a>
<div className="ml-auto">
<LanguageSwitcher currentLocale={locale as Locale} />
</div>
</nav>
{children}
</TranslationProvider>
</body>
</html>
);
}
Translation Context
"use client";
import { createContext, useContext } from "react";
import { createTranslator } from "@/lib/i18n/translations";
import type { Locale } from "@/lib/i18n/config";
type Messages = Record<string, Record<string, string>>;
type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
const TranslationContext = createContext<{
t: TranslateFunction;
locale: Locale;
}>({
t: (key) => key,
locale: "en",
});
export function TranslationProvider({
messages,
locale,
children,
}: {
messages: Messages;
locale: Locale;
children: React.ReactNode;
}) {
const t = createTranslator(messages);
return (
<TranslationContext.Provider value={{ t, locale }}>
{children}
</TranslationContext.Provider>
);
}
export function useTranslation() {
return useContext(TranslationContext);
}
Language Switcher
"use client";
import { usePathname } from "next/navigation";
import { locales, localeNames, type Locale } from "@/lib/i18n/config";
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname();
function switchLocale(newLocale: Locale) {
// Replace current locale in path
const segments = pathname.split("/");
segments[1] = newLocale;
const newPath = segments.join("/");
// Save preference
document.cookie = `locale=${newLocale};path=/;max-age=${60 * 60 * 24 * 365}`;
window.location.href = newPath;
}
return (
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value as Locale)}
className="text-sm border rounded-md px-2 py-1"
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
);
}
SEO: Hreflang Tags
// app/[locale]/layout.tsx β add to <head>
import { locales } from "@/lib/i18n/config";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const baseUrl = process.env.NEXT_PUBLIC_APP_URL!;
return {
alternates: {
canonical: `${baseUrl}/${locale}`,
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}`]),
),
},
};
}
Need Multilingual Websites?
We build internationalized websites that reach global audiences. Contact us to expand your reach.