← Volver a Pergaminos
Tutorial··1 min de lectura
Building a Bilingual Next.js Site with next-intl
A practical guide to implementing full bilingual support in a Next.js App Router project. Locale routing, translated URLs, JSON translation files, and the gotchas we hit along the way.
Summary
You implemented a fully bilingual English/Spanish Next.js site using the App Router and next-intl, with:
- Locale-prefixed routing and translated URL slugs (e.g.
/en/scrolls→/es/pergaminos) viapathnamesin the routing config. - A clear i18n setup with:
src/i18n/routing.tsfor locales, default locale, and translated pathnames.src/i18n/navigation.tsfor typedLink,usePathname,useRouter, andredirectthat are locale-aware.src/i18n/request.tsfor loading the correct messages per locale.src/middleware.tsfor locale detection and redirects, with careful exclusions (API, Studio, static, RSS).- All routes nested under
[locale]/(site)/so the locale param is injected automatically. - Server components using
getTranslations("Namespace"), client components usinguseTranslations("Namespace"), and every page callingsetRequestLocale(locale)to enable static generation. - JSON translation files per language (
src/i18n/messages/en.json,es.json), organized by component/page namespace (~195 strings each) for maintainability. - A language switcher that uses
next/navigation'suseRouterdirectly to swap the locale prefix while preserving the rest of the path.
Key lessons:
- Always import
Linkfrom@/i18n/navigation, notnext/link, or locale routing silently breaks. setRequestLocaleis mandatory for static generation with multiple locales.- Middleware matchers must explicitly exclude non-page routes.
Outcome:
The architecture is clean, scalable, and makes adding new languages straightforward: add a locale to the config, create a new JSON file, and translate—no structural changes required.
src/i18n/routing.tsimport {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['en', 'es'], defaultLocale: 'en', pathnames: { '/': { en: '/', es: '/', }, '/scrolls': { en: '/scrolls', es: '/pergaminos', }, '/about': { en: '/about', es: '/acerca-de', }, }, }); export type AppLocale = (typeof routing)['locales'][number];
src/i18n/navigation.tsimport {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; import {routing} from './routing'; export const { Link, redirect, usePathname, useRouter, } = createLocalizedPathnamesNavigation(routing);
src/i18n/request.tsimport {getRequestConfig} from 'next-intl/server'; export default getRequestConfig(async ({locale}) => { const messages = (await import(`./messages/${locale}.json`)).default; return { locale, messages, }; });
src/middleware.tsimport createMiddleware from 'next-intl/middleware'; import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { matcher: [ '/((?!api|studio|_next|favicon.ico|robots.txt|sitemap.xml|rss.xml).*)', ], };
src/app/[locale]/(site)/scrolls/page.tsximport {getTranslations, setRequestLocale} from 'next-intl/server'; import type {AppLocale} from '@/i18n/routing'; interface PageProps { params: {locale: AppLocale}; } export default async function ScrollsPage({params: {locale}}: PageProps) { setRequestLocale(locale); const t = await getTranslations('ScrollsPage'); return ( <main> <h1>{t('title')}</h1> <p>{t('description')}</p> </main> ); }
src/components/LanguageSwitcher.tsx"use client"; import {usePathname, useRouter} from 'next/navigation'; import {routing} from '@/i18n/routing'; export function LanguageSwitcher() { const router = useRouter(); const pathname = usePathname(); const switchTo = (nextLocale: (typeof routing)['locales'][number]) => { const segments = pathname.split('/').filter(Boolean); segments[0] = nextLocale; // replace locale prefix const nextPath = '/' + segments.join('/'); router.push(nextPath); }; return ( <div> <button onClick={() => switchTo('en')}>English</button> <button onClick={() => switchTo('es')}>Español</button> </div> ); }
Escrito por Conrado Bojorquez
Content.authors.conrado-bojorquez en Bruma Studio
La Señal
Conocimientos técnicos, historias de proyectos y algún descubrimiento arcano ocasional. Sin spam. Cancela cuando quieras.
Enviamos 1–2 veces al mes.