Next.js — Internationalization (i18n)
Internationalization (i18n) makes your application usable across languages and regions. Next.js with next-intl provides a complete solution for translated content, localized routing, and locale-aware formatting. The library integrates with both Server and Client Components.
| 1 | npm install next-intl |
| 2 | |
| 3 | # Project structure: |
| 4 | # messages/en.json, messages/es.json, messages/fr.json |
| 5 | # src/i18n/routing.ts, src/i18n/request.ts, src/i18n/navigation.ts |
| 6 | # src/middleware.ts |
| 1 | // src/i18n/routing.ts |
| 2 | import { defineRouting } from "next-intl/routing"; |
| 3 | |
| 4 | export const routing = defineRouting({ |
| 5 | locales: ["en", "es", "fr", "de"], |
| 6 | defaultLocale: "en", |
| 7 | pathnames: { |
| 8 | "/about": { |
| 9 | en: "/about", |
| 10 | es: "/sobre", |
| 11 | fr: "/a-propos", |
| 12 | de: "/ueber-uns", |
| 13 | }, |
| 14 | "/blog": { |
| 15 | en: "/blog", |
| 16 | es: "/blog", |
| 17 | fr: "/blogue", |
| 18 | de: "/blog", |
| 19 | }, |
| 20 | }, |
| 21 | }); |
| 22 | |
| 23 | // src/i18n/request.ts |
| 24 | import { getRequestConfig } from "next-intl/server"; |
| 25 | import { routing } from "./routing"; |
| 26 | |
| 27 | export default getRequestConfig(async ({ requestLocale }) => { |
| 28 | let locale = await requestLocale; |
| 29 | if (!locale || !routing.locales.includes(locale as any)) { |
| 30 | locale = routing.defaultLocale; |
| 31 | } |
| 32 | return { |
| 33 | locale, |
| 34 | messages: (await import("../../messages/" + locale + ".json")).default, |
| 35 | }; |
| 36 | }); |
| 37 | |
| 38 | // src/i18n/navigation.ts |
| 39 | import { createNavigation } from "next-intl/navigation"; |
| 40 | import { routing } from "./routing"; |
| 41 | |
| 42 | export const { Link, redirect, usePathname, useRouter } = |
| 43 | createNavigation(routing); |
| 1 | { |
| 2 | "common": { |
| 3 | "welcome": "Welcome to our app", |
| 4 | "loading": "Loading...", |
| 5 | "error": "Something went wrong", |
| 6 | "save": "Save", |
| 7 | "cancel": "Cancel" |
| 8 | }, |
| 9 | "home": { |
| 10 | "title": "Home", |
| 11 | "description": "This is the home page", |
| 12 | "features": { |
| 13 | "title": "Features", |
| 14 | "fast": "Lightning fast", |
| 15 | "secure": "Secure by default" |
| 16 | } |
| 17 | }, |
| 18 | "blog": { |
| 19 | "title": "Blog", |
| 20 | "readMore": "Read more", |
| 21 | "publishedOn": "Published on {date}", |
| 22 | "author": "By {name}", |
| 23 | "comments": "{count, plural, =0 {No comments} one {1 comment} other {{count} comments}}" |
| 24 | }, |
| 25 | "profile": { |
| 26 | "greeting": "Hello, {name}!", |
| 27 | "items": "{count, plural, =0 {You have no items} one {You have 1 item} other {You have {count} items}}" |
| 28 | } |
| 29 | } |
| 1 | { |
| 2 | "common": { |
| 3 | "welcome": "Bienvenido a nuestra aplicacion", |
| 4 | "loading": "Cargando...", |
| 5 | "error": "Algo salio mal", |
| 6 | "save": "Guardar", |
| 7 | "cancel": "Cancelar" |
| 8 | }, |
| 9 | "home": { |
| 10 | "title": "Inicio", |
| 11 | "description": "Esta es la pagina de inicio", |
| 12 | "features": { |
| 13 | "title": "Caracteristicas", |
| 14 | "fast": "Ultras rapido", |
| 15 | "secure": "Seguro por defecto" |
| 16 | } |
| 17 | } |
| 18 | } |
info
| 1 | // Server Component — use getTranslations |
| 2 | import { getTranslations } from "next-intl/server"; |
| 3 | |
| 4 | export default async function HomePage() { |
| 5 | const t = await getTranslations("home"); |
| 6 | |
| 7 | return ( |
| 8 | <div> |
| 9 | <h1>{t("title")}</h1> |
| 10 | <p>{t("description")}</p> |
| 11 | <p>{t("features.fast")}</p> |
| 12 | <p>{t("features.secure")}</p> |
| 13 | </div> |
| 14 | ); |
| 15 | } |
| 16 | |
| 17 | // Client Component — use useTranslations |
| 18 | "use client"; |
| 19 | import { useTranslations, useLocale } from "next-intl"; |
| 20 | |
| 21 | export function BlogPost({ post }: { post: Post }) { |
| 22 | const t = useTranslations("blog"); |
| 23 | const locale = useLocale(); |
| 24 | |
| 25 | return ( |
| 26 | <article> |
| 27 | <h2>{post.title}</h2> |
| 28 | <p>{t("author", { name: post.author })}</p> |
| 29 | <p>{t("publishedOn", { date: post.createdAt.toLocaleDateString(locale) })}</p> |
| 30 | <p>{t("comments", { count: post.comments.length })}</p> |
| 31 | </article> |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | // Language switcher |
| 36 | "use client"; |
| 37 | import { useLocale } from "next-intl"; |
| 38 | import { useRouter, usePathname } from "@/i18n/navigation"; |
| 39 | |
| 40 | export function LanguageSwitcher() { |
| 41 | const locale = useLocale(); |
| 42 | const router = useRouter(); |
| 43 | const pathname = usePathname(); |
| 44 | |
| 45 | function switchLocale(newLocale: string) { |
| 46 | router.replace(pathname, { locale: newLocale }); |
| 47 | } |
| 48 | |
| 49 | return ( |
| 50 | <select value={locale} onChange={(e) => switchLocale(e.target.value)}> |
| 51 | <option value="en">English</option> |
| 52 | <option value="es">Espanol</option> |
| 53 | <option value="fr">Francais</option> |
| 54 | <option value="de">Deutsch</option> |
| 55 | </select> |
| 56 | ); |
| 57 | } |
| 1 | "use client"; |
| 2 | import { useFormatter, useNow, useTimeZone } from "next-intl"; |
| 3 | |
| 4 | export function LocalizedContent() { |
| 5 | const format = useFormatter(); |
| 6 | const now = useNow(); |
| 7 | const timeZone = useTimeZone(); |
| 8 | |
| 9 | return ( |
| 10 | <div> |
| 11 | {/* Number formatting */} |
| 12 | <p>{format.number(1234567.89, { style: "currency", currency: "USD" })}</p> |
| 13 | {/* en: $1,234,567.89 | es: 1.234.567,89 $ | de: 1.234.567,89 $ */} |
| 14 | |
| 15 | <p>{format.number(0.856, { style: "percent" })}</p> |
| 16 | {/* en: 85.6% | es: 85,6% */} |
| 17 | |
| 18 | <p>{format.number(1234567, { notation: "compact" })}</p> |
| 19 | {/* en: 1.2M | es: 1,2M */} |
| 20 | |
| 21 | {/* Date formatting */} |
| 22 | <p>{format.dateTime(now, { dateStyle: "full" })}</p> |
| 23 | {/* en: Monday, July 14, 2026 | es: lunes, 14 de julio de 2026 */} |
| 24 | |
| 25 | <p>{format.dateTime(now, { timeStyle: "short" })}</p> |
| 26 | {/* en: 2:30 PM | es: 14:30 */} |
| 27 | |
| 28 | {/* Relative time */} |
| 29 | <p>{format.relativeTime(now, new Date("2026-07-10"))}</p> |
| 30 | {/* "4 days ago" */} |
| 31 | |
| 32 | {/* Time zone */} |
| 33 | <p>Current timezone: {timeZone}</p> |
| 34 | |
| 35 | {/* Plural rules */} |
| 36 | <p>{format.plural(0, { one: "# item", other: "# items" })}</p> |
| 37 | <p>{format.plural(5, { one: "# item", other: "# items" })}</p> |
| 38 | </div> |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | // Server-side formatting |
| 43 | import { getFormatter, getTranslations } from "next-intl/server"; |
| 44 | |
| 45 | export default async function ServerFormatted() { |
| 46 | const format = await getFormatter(); |
| 47 | const t = await getTranslations("common"); |
| 48 | |
| 49 | const price = format.number(29.99, { style: "currency", currency: "EUR" }); |
| 50 | const date = format.dateTime(new Date(), { dateStyle: "long" }); |
| 51 | |
| 52 | return ( |
| 53 | <div> |
| 54 | <p>{t("welcome")}</p> |
| 55 | <p>Price: {price}</p> |
| 56 | <p>Date: {date}</p> |
| 57 | </div> |
| 58 | ); |
| 59 | } |
| 1 | // Strategy 1: Locale prefix (default) |
| 2 | // /en/about, /es/about, /fr/about |
| 3 | |
| 4 | // Strategy 2: No prefix (locale from cookie/header) |
| 5 | // /about (locale determined by Accept-Language or cookie) |
| 6 | |
| 7 | // Strategy 3: Domain-based |
| 8 | // en.example.com/about |
| 9 | // es.example.com/about |
| 10 | |
| 11 | // middleware.ts — handle locale detection |
| 12 | import createMiddleware from "next-intl/middleware"; |
| 13 | import { routing } from "./i18n/routing"; |
| 14 | |
| 15 | export default createMiddleware(routing); |
| 16 | |
| 17 | export const config = { |
| 18 | matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], |
| 19 | }; |
| 20 | |
| 21 | // next.config.js — add next-intl plugin |
| 22 | const createNextIntlPlugin = require("next-intl/plugin"); |
| 23 | const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); |
| 24 | |
| 25 | /** @type {import('next').NextConfig} */ |
| 26 | const nextConfig = {}; |
| 27 | module.exports = withNextIntl(nextConfig); |
| 28 | |
| 29 | // Locale detection priority: |
| 30 | // 1. Cookie (user's previous choice) |
| 31 | // 2. Accept-Language header (browser preference) |
| 32 | // 3. Default locale fallback |
info
1. Use getTranslations() in Server Components and useTranslations() in Client Components.
2. Organize message files by feature, not by language. Keep keys consistent across locales.
3. Use ICU message format for plurals and interpolation — it handles edge cases that string concatenation cannot.
4. Use format.dateTime() and format.number() instead of raw Date.toLocaleDateString() for consistent locale-aware formatting.
5. Use the Link component from next-intl/navigation — it automatically prefixes routes with the current locale.
6. Set hrefLang in your metadata for SEO — search engines use it to serve the correct language version.