DEV Community

Cover image for I migrated my 8-language app to Next.js 16. Then Google Search Console screamed at me.
dev.chinasurvival
dev.chinasurvival

Posted on • Originally published at chinasurvival.com

I migrated my 8-language app to Next.js 16. Then Google Search Console screamed at me.

I thought I was done.

I had just finished a massive migration of China Survival Kit—a travel tool I built for tourists—moving it to the bleeding edge of Next.js 16 (App Router). The performance metrics were green. The next-intl integration for 8 languages (English, Japanese, Korean, etc.) was working perfectly in the browser.

I pushed to production, felt good, and went to sleep.

Two days later, I opened Google Search Console (GSC). It was a bloodbath.

"Page is not indexed: Duplicate without user-selected canonical."

Google was refusing to index my city guides (/en/guides/cities, /ja/guides/cities). It decided that my English page was a "duplicate" of my root domain and essentially de-indexed the specialized content I had spent weeks building.

If you are building a multilingual site on Vercel with Next.js, you might walk into this exact trap. Here is what happened, and the stupidly simple fix that saved my SEO.

The Architecture

Just for context, here is the stack I'm running. It's designed for tourists in China who often have spotty roaming data, so speed is everything:

  • Core: Next.js 16 (Server Components are a godsend for reducing bundle size).
  • UI: shadcn/ui (Tailwind CSS).
  • i18n: next-intl handling routing (/en, /ja, /ko).
  • Hosting: Vercel.

The Trap: When "Relative" becomes "Irrelevant"

Google's crawler is smart, but it's also incredibly rigid.

My site structure looks like this:

  • chinasurvival.com (Root, redirects based on locale)
  • chinasurvival.com/en/...
  • chinasurvival.com/ja/...

I had standard canonical tags in my metadata. Or so I thought.

When I inspected the source code of my production build, I realized Next.js (during the build process on Vercel) wasn't always generating the URL I expected. In some build environments, process.env.NEXT_PUBLIC_SITE_URL was undefined or falling back to localhost.

So my canonical tag rendered like this:

<link rel="canonical" href="http://localhost:3000/en/guides/cities" />

Google ignores localhost URLs. Since it considered the canonical tag invalid, it fell back to its own logic: "Hey, this /en page looks exactly like the root page. I'll just index the root and throw this one in the trash."

The Fix: Hardcode Your Production Reality

We developers love environment variables. We love making things dynamic. But for SEO on a static site generator? Consistency is king.

I stopped trying to be clever with dynamic base URLs for the canonical tags. I forced the production URL to be the absolute truth.

1. The "Brute Force" Canonical

In my lib/seo.ts, I updated my metadata generator to ensure it never relies on a flaky build-time variable for the domain name.

// lib/seo.ts  
import { Metadata } from 'next';

export function constructMetadata({  
  title,  
  description,  
  path \= '',  
  locale \= 'en'  
}: MetadataProps): Metadata {  

  // 🛑 STOP doing this:  
  // const siteUrl \= process.env.NEXT\_PUBLIC\_SITE\_URL;   
  // const siteUrl \= process.env.VERCEL\_URL; // Don't do this either\!  

  // ✅ DO this. Force the bot to see the real domain.  
  const siteUrl \= '\[https://www.chinasurvival.com\](https://www.chinasurvival.com)';  

  // Ensure path starts with a slash  
  const cleanPath \= path.startsWith('/') ? path : \`/${path}\`;  

  // Construct the absolute URL  
  const canonicalUrl \= \`${siteUrl}/${locale}${cleanPath}\`;

  return {  
    title,  
    description,  
    alternates: {  
      canonical: canonicalUrl, // This must be bulletproof  
      languages: {  
        'en': \`${siteUrl}/en${cleanPath}\`,  
        'ja': \`${siteUrl}/ja${cleanPath}\`,  
        'ko': \`${siteUrl}/ko${cleanPath}\`,  
        'de': \`${siteUrl}/de${cleanPath}\`,  
        // ... other languages  

        // Crucial: Tells Google "If no language matches, send them here"  
        'x-default': \`${siteUrl}/en${cleanPath}\`,   
      },  
    },  
  };  
}
Enter fullscreen mode Exit fullscreen mode

Why not use VERCEL_URL?

You might ask: "Why didn't you just use the system environment variable?" > Because VERCEL_URL is dynamic. On preview deployments, it generates git-branch-project.vercel.app. I don't want my canonical tags pointing to temporary preview domains; I want them pointing to the one true production domain regardless of where the build is happening.

2. Moving SEO to Server Components

With Next.js 16, I moved all data fetching for SEO into the page.tsx itself. No more useEffect nonsense.

Since I store my SEO strings in translation files (en.json, ja.json), I use next-intl on the server side to pull them out dynamically based on the slug.

// app/\[locale\]/guides/cities/\[slug\]/page.tsx

import { constructMetadata } from '@/lib/seo';  
import { getTranslations } from 'next-intl/server';

// The "magic" map to match slugs to translation keys  
const slugToKeyMap: Record\<string, string\> \= {  
    'beijing': 'Guides\_Cities\_Beijing',  
    'shanghai': 'Guides\_Cities\_Shanghai',  
    // ... other cities  
};

export async function generateMetadata({ params }: Props) {  
    // ⚠️ Next.js 16 BREAKING CHANGE: params is now a Promise\!  
    // If you don't await this, your build will fail.  
    const { locale, slug } \= await params;  

    const jsonKey \= slugToKeyMap\[slug\];

    // Dynamic Server-Side Translation Fetching  
    const t \= await getTranslations({ locale, namespace: jsonKey });

    return constructMetadata({  
        title: t('meta\_title'), // "Beijing Travel Guide 2025..."  
        description: t('meta\_description'),  
        path: \`/guides/cities/${slug}\`, // Passing the specific path for the canonical  
        locale,  
    });  
}
Enter fullscreen mode Exit fullscreen mode

Note on Next.js 15/16: Notice the line const { locale, slug } = await params;. In the latest versions of Next.js, params and searchParams are asynchronous. If you try to access params.slug directly without awaiting it, you will get a nasty runtime error.

3. The Sitemap Strategy

I also updated sitemap.ts to use flatMap. This ensures that even if Google lands on the English page, the sitemap explicitly hands over the Japanese and Korean versions on a silver platter.

// app/sitemap.ts

const routes \= \[  
  '',   
  '/guides/cities',  
  '/guides/internet',  
  // ... other static routes  
\];

const locales \= \['en', 'ko', 'ja', 'es', 'fr', 'de', 'ru', 'pt'\];

export default function sitemap(): MetadataRoute.Sitemap {  
    // Force production URL here too  
    const baseUrl \= '\[https://www.chinasurvival.com\](https://www.chinasurvival.com)'; 

    return locales.flatMap((locale) \=\> {  
        return routes.map((route) \=\> ({  
            url: \`${baseUrl}/${locale}${route}\`,  
            lastModified: new Date(),  
            priority: route \=== '' ? 1.0 : 0.8,  
        }));  
    });  
}
Enter fullscreen mode Exit fullscreen mode

**The Aftermath

I deployed the fix. I went back to GSC and hit "Inspect URL" on the /en/guides/cities page.

Result:
The canonical URL was finally reading https://www.chinasurvival.com/en/guides/cities.

I hit "Request Indexing". 48 hours later, the "Duplicate" error vanished, and my Japanese pages started showing up in search results for users in Tokyo.

Lesson learned: When it comes to SEO metadata, explicit is better than implicit. Don't trust your build environment to guess your URL.

I built this architecture to power **China Survival Kit*, a tool helping travelers navigate the Alipay, and local transport and city guides. 👉 Check out the live app here to see the i18n routing in action.

Top comments (0)