<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 张宫卿</title>
    <description>The latest articles on DEV Community by 张宫卿 (@zhanggongqing).</description>
    <link>https://dev.to/zhanggongqing</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4055108%2F3be16d66-3ab6-4102-b805-a9234025432c.png</url>
      <title>DEV Community: 张宫卿</title>
      <link>https://dev.to/zhanggongqing</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zhanggongqing"/>
    <language>en</language>
    <item>
      <title>Adding a Spanish subtree to a Next.js 16 App Router site without touching the English routes</title>
      <dc:creator>张宫卿</dc:creator>
      <pubDate>Thu, 30 Jul 2026 12:59:43 +0000</pubDate>
      <link>https://dev.to/zhanggongqing/adding-a-spanish-subtree-to-a-nextjs-16-app-router-site-without-touching-the-english-routes-4jpd</link>
      <guid>https://dev.to/zhanggongqing/adding-a-spanish-subtree-to-a-nextjs-16-app-router-site-without-touching-the-english-routes-4jpd</guid>
      <description>&lt;p&gt;When I decided to ship a Spanish version of &lt;a href="https://randomtopics.app" rel="noopener noreferrer"&gt;Random Topics&lt;/a&gt;, a small Next.js content site, I had one hard constraint: &lt;strong&gt;the existing English routes could not change.&lt;/strong&gt; They were indexed, ranking, and I didn't want to risk a URL migration just to bolt on a second language.&lt;/p&gt;

&lt;p&gt;The default advice for i18n in the App Router is to put everything under a &lt;code&gt;[lang]&lt;/code&gt; segment — &lt;code&gt;/en/...&lt;/code&gt; and &lt;code&gt;/es/...&lt;/code&gt;. That's clean for a greenfield project. But it means every English URL that already exists (&lt;code&gt;/debate&lt;/code&gt;, &lt;code&gt;/conversation&lt;/code&gt;, &lt;code&gt;/topics/would-you-rather-questions&lt;/code&gt;) suddenly becomes &lt;code&gt;/en/debate&lt;/code&gt;, and you're now maintaining 301s for your entire sitemap on day one.&lt;/p&gt;

&lt;p&gt;So I went with a different shape that I don't see written up much: &lt;strong&gt;keep the English site at the root, and add Spanish as a subtree.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The English tree never moves. Spanish lives entirely under &lt;code&gt;/es&lt;/code&gt;. Here's what I learned making that work on &lt;strong&gt;Next.js 16 + React 19&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The content merge layer is what keeps it maintainable
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;merge layer&lt;/strong&gt;. The Spanish modules contain only the fields that differ, keyed by the stable topic ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data/topics.ts     → structural source of truth&lt;/span&gt;
&lt;span class="c1"&gt;// data/topics.es.ts  → translated text fields&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLocalizedTopics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;translation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;topicsEs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;translation&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;talkingPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;talkingPoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Parallel route trees can still share the important parts
&lt;/h3&gt;

&lt;p&gt;App Router route segments are file-based, so the English and Spanish entry points are separate files. Each dynamic tree exports its own &lt;code&gt;generateStaticParams&lt;/code&gt;, while both read from shared slug/data sources and reuse the same generator, navigation, footer, cards, and filtering components.&lt;/p&gt;

&lt;p&gt;For example, &lt;a href="https://randomtopics.app/debate" rel="noopener noreferrer"&gt;randomtopics.app/debate&lt;/a&gt; and &lt;code&gt;/es/debate&lt;/code&gt; are separate page entry points, but both render the shared &lt;code&gt;TopicGenerator&lt;/code&gt;; the Spanish page passes &lt;code&gt;locale="es"&lt;/code&gt;. This small amount of routing duplication is intentional: localized editorial copy and metadata can differ without forking the interactive product logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/i18n/config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locales&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;defaultLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because English is the root (not &lt;code&gt;/en&lt;/code&gt;), &lt;code&gt;defaultLocale&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. hreflang is the part everyone gets wrong
&lt;/h3&gt;

&lt;p&gt;This is where the subtree pattern needs care. Google needs &lt;strong&gt;reciprocal&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;I generate the annotations off a single mapping so they can't drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// for /debate  ↔  /es/debate&lt;/span&gt;
&lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://randomtopics.app/debate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://randomtopics.app/es/debate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://randomtopics.app/debate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bilingual &lt;code&gt;sitemap.ts&lt;/code&gt; is the reciprocal source of truth: it emits one entry per locale, and each entry advertises the full &lt;code&gt;en&lt;/code&gt; / &lt;code&gt;es&lt;/code&gt; / &lt;code&gt;x-default&lt;/code&gt; set. Localized pages also add the same mapping through Next.js metadata where useful. The Spanish home (&lt;a href="https://randomtopics.app/es" rel="noopener noreferrer"&gt;randomtopics.app/es&lt;/a&gt;) points back to English, while &lt;code&gt;x-default&lt;/code&gt; consistently resolves to the root site.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Things that bit me
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;generateStaticParams&lt;/code&gt; runs per-route-segment.&lt;/strong&gt; The English &lt;code&gt;topics/[slug]&lt;/code&gt; and the Spanish &lt;code&gt;es/topics/[slug]&lt;/code&gt; each need their own export. I assumed one would cover both. It doesn't — they're different segments to the router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata is not inherited across the tree.&lt;/strong&gt; &lt;code&gt;/es&lt;/code&gt; needs its own &lt;code&gt;metadata&lt;/code&gt; (or &lt;code&gt;generateMetadata&lt;/code&gt;) with the Spanish &lt;code&gt;title&lt;/code&gt;/&lt;code&gt;description&lt;/code&gt;, otherwise you ship a Spanish page with an English &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, which tanks the CTR you built the whole subtree for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't forget &lt;code&gt;robots&lt;/code&gt; and canonical self-references&lt;/strong&gt; on the mirrored pages, or you'll get the Spanish page treated as a near-duplicate of the English one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Would I do it again?
&lt;/h3&gt;

&lt;p&gt;For a &lt;strong&gt;greenfield&lt;/strong&gt; multilingual site, use the &lt;code&gt;[lang]&lt;/code&gt; segment — it's more symmetric and scales past two languages cleanly. But for an &lt;strong&gt;established site where the primary language is already ranking&lt;/strong&gt;, the subtree pattern let me ship a second language with &lt;strong&gt;zero redirects and zero risk to existing URLs&lt;/strong&gt;, which was worth the small asymmetry of maintaining an &lt;code&gt;es/&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;If you want to poke at the result, the English site is at &lt;a href="https://randomtopics.app" rel="noopener noreferrer"&gt;randomtopics.app&lt;/a&gt; and the Spanish subtree at &lt;a href="https://randomtopics.app/es" rel="noopener noreferrer"&gt;/es&lt;/a&gt; — shared product components, a merged topic layer, and reciprocal sitemap hreflang.&lt;/p&gt;

&lt;p&gt;Happy to answer anything about the merge layer or the hreflang setup in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
