Ir al contenido
← 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) via pathnames in the routing config.
  • A clear i18n setup with:
  • src/i18n/routing.ts for locales, default locale, and translated pathnames.
  • src/i18n/navigation.ts for typed Link, usePathname, useRouter, and redirect that are locale-aware.
  • src/i18n/request.ts for loading the correct messages per locale.
  • src/middleware.ts for 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 using useTranslations("Namespace"), and every page calling setRequestLocale(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's useRouter directly to swap the locale prefix while preserving the rest of the path.

Key lessons:

  • Always import Link from @/i18n/navigation, not next/link, or locale routing silently breaks.
  • setRequestLocale is 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.ts
import {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.ts
import {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; import {routing} from './routing'; export const { Link, redirect, usePathname, useRouter, } = createLocalizedPathnamesNavigation(routing);
src/i18n/request.ts
import {getRequestConfig} from 'next-intl/server'; export default getRequestConfig(async ({locale}) => { const messages = (await import(`./messages/${locale}.json`)).default; return { locale, messages, }; });
src/middleware.ts
import 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.tsx
import {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.