This site is fully in English and Spanish. English is the default and Spanish is one click away, in the header. Every page has its own address in each language — /en/about and /es/sobre-mi, /en/contact and /es/contacto — and Google knows they're the same page in two languages, not two pages competing with each other.
All of it is built on the Next.js 16 App Router, with no internationalisation library and no middleware. This post walks through how, with the real code, and above all why each decision is the way it is.
Each language has its own URL. No serving English or Spanish at the same address depending on who's asking. A page that changes with the Accept-Language header can't be cached reliably, can't be linked knowing what the other person will see and — most importantly for search — is always crawled in the same language. Half the site vanishes from the index.
Routes are translated. /es/sobre-mi, not /es/about. People searching in Spanish type in Spanish, and the URL is part of what they see in the result. It's more work — a static folder can only have one name — and most of this post is about paying that cost once.
One explicit default language. The root / goes to /en, and the x-default in hreflang points to English. Spanish isn't a second-class translation: it's the language the content is written in first. But the audience reaching the site from outside the Dominican Republic searches in English.
The whole site hangs from app/[locale], and that segment holds the root layout, the one that writes . That matters for a specific reason: the lang attribute has to be in the served HTML, not patched in later with JavaScript. It's what screen readers and search engines read.
dynamicParams = false makes any language not on the list an immediate 404. Without that line, Next.js would try to render /fr on demand and cache it.
There's a price: with one root layout per language, switching language reloads the whole document. I accepted it. It's something you do once per visit, and in exchange each language's HTML is right from the first byte. The other consequence is that a route outside any language has no layout to render its 404; that's what app/global-not-found.tsx is for, currently enabled with experimental.globalNotFound.
The obvious way to have /es/proyectos and /en/projects is two folders. That works for a one-off page — privacy has privacidad/ and privacy/, each only in its own language — but it doesn't scale: every child page would have to be duplicated, and its code with it.
The usual alternative, a rewrite (in next.config or middleware), has a trap: usePathname returns one route on the server and another on the client, and any component that marks "you are here" ends up out of sync.
So sections are a dynamic segment: app/[locale]/[mundo], with children in [mundo]/[sub] and [mundo]/[sub]/[objeto]. Each section's slug lives in the frontmatter of its content, per language:
The two versions are joined by id, never by slug. Segments that don't belong to any section — servicios/services, gracias/thanks, observatorio/observatory — live in a separate module with no dependencies, so client code can read them too:
Every dynamic page declares its generateStaticParams in both languages and also sets dynamicParams = false. The result: the whole site is generated at build time, and /en/proyectos — the Spanish slug under English — is a 404, not a duplicate page.
This is the piece that makes everything else easy. Nowhere in the code is a page named by its route. It's named by what it is:
One function turns a PageRef into its route for a language, and another returns the same page in every published language:
The language switcher, each page's hreflang, the sitemap and internal links all draw from that function. Nobody builds a URL by hand. If /es/proyectos were renamed /es/trabajo tomorrow, one line of frontmatter changes and everything else — header links and sitemap included — follows.
Because PageRef is a discriminated union, the switch that walks it is exhaustive: adding a new page without saying what its route is becomes a TypeScript error, not a broken link in production.
Without hreflang, Google sees /es/sobre-mi and /en/about as two pages on the same topic and picks one, or alternates between them. With it, it knows they're the same page and shows each person the one in their language.
