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

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:
| Component | Tech |
|---|---|
| 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 fell back to localhost.
So my canonical tag rendered like this:
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: Hard‑code 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 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';
// 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}`,
},
},
};
}
Why not use VERCEL_URL?
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';
// Map 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!
const { locale, slug } = await params;
const t = await getTranslations('guides.cities');
const titleKey = slugToKeyMap[slug] ?? 'Guides_Cities_Default';
const title = t(`${titleKey}.title`);
const description = t(`${titleKey}.description`);
return constructMetadata({
title,
description,
path: `/guides/cities/${slug}`,
locale,
});
}
// ...rest of the page component
Now every page renders a bulletproof canonical URL that always points to https://www.chinasurvival.com, no matter where the build runs.
TL;DR
- Never rely on a runtime environment variable for canonical URLs. Hard‑code the production domain (or inject it at build time in a controlled way).
- Generate SEO metadata on the server (App Router) so the correct canonical,
hreflang, andx‑defaulttags are present in the HTML sent to crawlers. - Verify the rendered
<link rel="canonical">in production – it should be an absolute URL that Google can crawl.
After deploying these changes, Google re‑indexed the /en/guides/cities and /ja/guides/cities pages within a day, and the SEO health of the site returned to green. 🎉
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,
});
Note on Next.js 15/16
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';
return locales.flatMap((locale) => {
return routes.map((route) => ({
url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(),
priority: route === '' ? 1.0 : 0.8,
}));
});
}
The Aftermath
I deployed the fix, went back to Google Search Console, 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 clicked “Request Indexing.” After 48 hours, 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 Alipay, local transport, and city guides.
👉 Check out the live app here to see the i18n routing in action.
