When I decided to ship a Spanish version of Random Topics, a small Next.js content site, I had one hard constraint: the existing English routes could not change. They were indexed, ranking, and I didn't want to risk a URL migration just to bolt on a second language.
The default advice for i18n in the App Router is to put everything under a [lang] segment — /en/... and /es/.... That's clean for a greenfield project. But it means every English URL that already exists (/debate, /conversation, /topics/would-you-rather-questions) suddenly becomes /en/debate, and you're now maintaining 301s for your entire sitemap on day one.
So I went with a different shape that I don't see written up much: keep the English site at the root, and add Spanish as a subtree.
src/app/
page.tsx → / (English, unchanged)
debate/page.tsx → /debate (English, unchanged)
topics/[slug]/ → /topics/... (English, unchanged)
es/
page.tsx → /es (Spanish home)
debate/page.tsx → /es/debate (Spanish mirror)
topics/[slug]/ → /es/topics/... (Spanish mirror)
The English tree never moves. Spanish lives entirely under /es. Here's what I learned making that work on Next.js 16 + React 19.
1. The content merge layer is what keeps it maintainable
The naive version of a mirror is copy-paste: duplicate every data file, translate it, and now you have two files drifting apart forever. For the 500+ topic database, I used a merge layer. The Spanish modules contain only the fields that differ, keyed by the stable topic ID:
// data/topics.ts → structural source of truth
// data/topics.es.ts → translated text fields
export function getLocalizedTopics(locale: 'en' | 'es') {
if (locale === 'en') return topics;
return topics.map((topic) => {
const translation = topicsEs[topic.id];
return translation
? {
...topic,
text: translation.text,
talkingPoints: translation.talkingPoints,
}
: topic;
});
}
The win: IDs, categories, modes, and depth remain a single source of truth. If I add an English topic before its translation is ready, the Spanish generator inherits the entry and falls back to the English text instead of breaking. For a solo project that ships English-first, that graceful degradation is the whole ballgame.
2. Parallel route trees can still share the important parts
App Router route segments are file-based, so the English and Spanish entry points are separate files. Each dynamic tree exports its own generateStaticParams, while both read from shared slug/data sources and reuse the same generator, navigation, footer, cards, and filtering components.
For example, randomtopics.app/debate and /es/debate are separate page entry points, but both render the shared TopicGenerator; the Spanish page passes locale="es". This small amount of routing duplication is intentional: localized editorial copy and metadata can differ without forking the interactive product logic.
// src/i18n/config.ts
export const locales = ['en', 'es'] as const;
export const defaultLocale = 'en';
Because English is the root (not /en), defaultLocale never appears in a URL. That's the behavior most "no locale prefix for default language" configs fight the router to achieve — here you get it for free, because the English routes are just… the routes.
3. hreflang is the part everyone gets wrong
This is where the subtree pattern needs care. Google needs reciprocal hreflang: the English page must point at the Spanish one and vice versa, and each must include a self-reference. Miss the self-reference or the return link and Google silently ignores the whole cluster.
I generate the annotations off a single mapping so they can't drift:
// for /debate ↔ /es/debate
alternates: {
languages: {
'en': 'https://randomtopics.app/debate',
'es': 'https://randomtopics.app/es/debate',
'x-default': 'https://randomtopics.app/debate',
},
}
The bilingual sitemap.ts is the reciprocal source of truth: it emits one entry per locale, and each entry advertises the full en / es / x-default set. Localized pages also add the same mapping through Next.js metadata where useful. The Spanish home (randomtopics.app/es) points back to English, while x-default consistently resolves to the root site.
Google supports hreflang in HTML headers or XML sitemaps; using the sitemap for the complete reciprocal graph keeps route metadata from drifting as the page count grows.
4. Things that bit me
-
generateStaticParamsruns per-route-segment. The Englishtopics/[slug]and the Spanishes/topics/[slug]each need their own export. I assumed one would cover both. It doesn't — they're different segments to the router. -
Metadata is not inherited across the tree.
/esneeds its ownmetadata(orgenerateMetadata) with the Spanishtitle/description, otherwise you ship a Spanish page with an English<title>, which tanks the CTR you built the whole subtree for. -
Don't forget
robotsand canonical self-references on the mirrored pages, or you'll get the Spanish page treated as a near-duplicate of the English one.
Would I do it again?
For a greenfield multilingual site, use the [lang] segment — it's more symmetric and scales past two languages cleanly. But for an established site where the primary language is already ranking, the subtree pattern let me ship a second language with zero redirects and zero risk to existing URLs, which was worth the small asymmetry of maintaining an es/ folder.
If you want to poke at the result, the English site is at randomtopics.app and the Spanish subtree at /es — shared product components, a merged topic layer, and reciprocal sitemap hreflang.
Happy to answer anything about the merge layer or the hreflang setup in the comments.
Top comments (0)