When we set up alquila-sabanas, one of our Next.js projects, we knew from the very first commit that the site would have to speak several languages. It wasn't a last-minute patch with duplicated texts, but an architecture decision made from the start. We chose next-intl to solve it, and the result is a structure that now serves as a reference for any new project that starts with this need.
This article covers how we organized the pieces: the i18n/ and messages/ folders, the proxy.ts that decides the language before anything is rendered, loading translations in both server and client components, date and currency formatting, and multilingual SEO with hreflang and sitemap.
The foundation: routing and the middleware in proxy.ts
next-intl separates the language configuration from the rest of the code in a routing file, which lives inside i18n/. There we define which locales we support and which is the default language:
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['es', 'en', 'fr'],
defaultLocale: 'es'
});This routing object is reused in several places: in the middleware, in typed navigation, and in the sitemap. It's the single source of truth about which languages exist, so if we add Italian tomorrow, we only need to touch it in one place.
The middleware (in our case we keep it as proxy.ts at the root of the project) intercepts each request and decides which language prefix applies before reaching the routes:
// proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)']
};With this, /es/reservas, /en/bookings or /fr/reservations coexist without us having to write redirect logic by hand. The matcher excludes API routes and static assets, which don't need a language prefix.
Loading messages: Server Components and Client Components
The great advantage of next-intl over older React i18n solutions is that it handles server and client components differently, but coherently, which in Next.js with the App Router coexist on the same page.
In server components
In the locale's root layout we load the messages once and pass them to the provider:
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}Any Server Component within the tree can use getTranslations asynchronously without depending on the provider, which is convenient for pages that generate metadata or static content on the server.
In client components
For a booking button that needs interactivity, we use the standard hook, backed by the NextIntlClientProvider that already wraps the application:
'use client';
import { useTranslations } from 'next-intl';
export function BookingButton() {
const t = useTranslations('Booking');
return <button>{t('reserve')}</button>;
}The important thing is that NextIntlClientProvider only sends to the browser the messages that are actually used in the client tree, not the entire dictionary. That keeps the bundle lightweight even with several languages loaded.
Date and currency formatting
In a rental business, availability dates and prices appear on almost every screen. It's not enough to translate the "price" label: the number has to be formatted according to the language's convention. next-intl exposes useFormatter precisely for this:
import { useFormatter } from 'next-intl';
export function PriceTag({ date, amount }: { date: Date; amount: number }) {
const format = useFormatter();
return (
<span>
{format.dateTime(date, { dateStyle: 'long' })} — {format.number(amount, { style: 'currency', currency: 'EUR' })}
</span>
);
}For a user in French, the same date is written differently and the decimal separator of the price changes. Delegating this to the Intl formatter that next-intl wraps saves us from subtle bugs like "it works in Spanish but English shows the comma in the wrong place".
Multilingual SEO: hreflang and sitemap
There's little point in having a translated site if search engines don't know that several versions of the same page exist. Two pieces solve this:
- •Hreflang tags, generated in generateMetadata with alternates.languages, so each page declares its equivalents in other languages.
- •A sitemap that loops through the locales defined in routing and builds the alternative URLs automatically.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { routing } from '@/i18n/routing';
export default function sitemap(): MetadataRoute.Sitemap {
return routing.locales.map((locale) => ({
url: `https://alquila-sabanas.com/${locale}`,
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, `https://alquila-sabanas.com/${l}`])
)
}
}));
}By building the sitemap from the same routing the middleware uses, we make sure they never get out of sync: if a language is added, it appears in routes, in hreflang, and in the sitemap without having to touch three different places.
How we organize translations in messages/
The messages/ folder contains one JSON file per language, but within each file we group the keys by functional section instead of dumping them all into a single level:
{
"Common": {
"appName": "Alquila Sábanas"
},
"Booking": {
"reserve": "Reservar",
"cancel": "Cancelar"
},
"Footer": {
"contact": "Contacto"
}
}This organization by namespaces (Common, Booking, Footer) lets us use useTranslations('Booking') instead of long paths like t('booking.actions.reserve'), and makes it so that when reviewing a file you can see at a glance which part of the application each block covers.
Keeping the three languages with the same key structure is the trickiest part of the day-to-day work: if someone adds a key in es.json and forgets about en.json, the failure isn't always obvious until a user navigates in that language. At this point we incorporated an AI agent into the internal workflow: before each deployment, it checks that the keys match between files and suggests the missing translation, but the final approval of any text that goes to production is always made by a person on the team.
What we take away from this architecture
If you're going to internationalize a Next.js project from scratch, the order that has worked best for us is: define the routing and middleware first, then message loading on server and client, then formatting with Intl, and leave multilingual SEO for last, once the routes are stable. Trying to do it the other way around usually ends up in poorly generated hreflang tags because the URLs are still changing.
Translating loose texts isn't enough: internationalizing means deciding from the start how each version of the site will navigate, format, and rank in search engines.
As a next step, if your project already has next-intl installed but the namespaces in messages/ have grown without order, it's worth spending an afternoon regrouping them by section before the file becomes impossible to maintain.
