내 8개 언어 앱을 Next.js 16으로 마이그레이션했어요. 그러자 Google Search Console이 소리를 질렀다.
Source: Dev.to

나는 끝났다고 생각했다.
나는 방금 China Survival Kit—관광객을 위해 내가 만든 여행 도구—를 **Next.js 16 (App Router)**의 최신 버전으로 대대적으로 마이그레이션을 마쳤다. 성능 지표는 모두 초록색이었다. 8개 언어(영어, 일본어, 한국어 등)를 위한 next‑intl 통합도 브라우저에서 완벽히 작동했다.
프로덕션에 배포하고 기분이 좋았으며 잠자리에 들었다.
이틀 뒤, Google Search Console(GSC)을 열었더니 상황이 최악이었다.
“Page is not indexed: Duplicate without user‑selected canonical.”
Google은 내 도시 가이드(/en/guides/cities, /ja/guides/cities)를 색인하지 않았다. 영어 페이지가 루트 도메인의 중복이라고 판단해, 내가 몇 주 동안 정성 들여 만든 특화 콘텐츠를 사실상 색인 해제한 것이다.
Vercel에서 Next.js로 다국어 사이트를 구축하고 있다면, 바로 이 함정에 빠질 수 있다. 여기서 무슨 일이 있었는지, 그리고 내 SEO를 구해준 아주 간단한 해결책을 소개한다.
아키텍처
참고용으로 현재 사용 중인 스택을 소개합니다. 이 스택은 로밍 데이터가 불안정한 경우가 많은 중국 여행객을 위해 설계되었으며, 속도가 가장 중요합니다:
| 구성 요소 | 기술 |
|---|---|
| Core | Next.js 16 (Server Components는 번들 크기를 줄이는 데 큰 도움이 됩니다) |
| UI | shadcn/ui (Tailwind CSS) |
| i18n | next‑intl 라우팅 처리 (/en, /ja, /ko) |
| Hosting | Vercel |
함정: “Relative”(상대)가 “Irrelevant”(무관)으로 변할 때
Google의 크롤러는 똑똑하지만, 동시에 매우 경직되어 있습니다.
내 사이트 구조는 다음과 같습니다:
chinasurvival.com (Root, redirects based on locale)
├─ chinasurvival.com/en/…
└─ chinasurvival.com/ja/…
메타데이터에 표준 canonical 태그를 넣어 두었습니다. 그렇게 생각했죠.
하지만 프로덕션 빌드의 소스 코드를 확인해 보니, Vercel에서 빌드되는 동안 Next.js가 기대했던 URL을 항상 생성하지 않는다는 것을 알게 되었습니다. 일부 빌드 환경에서는 process.env.NEXT_PUBLIC_SITE_URL이 undefined이거나 localhost로 대체되었습니다.
그래서 canonical 태그는 다음과 같이 렌더링되었습니다:
Google은 localhost URL을 무시합니다. canonical 태그가 유효하지 않다고 판단한 Google은 자체 로직을 적용했습니다: “이 /en 페이지가 루트 페이지와 정확히 동일하네. 루트만 색인하고 이 페이지는 버릴게.”
Source: …
해결책: 프로덕션 현실을 하드코딩하기
우리는 개발자라서 환경 변수를 좋아하고, 동적으로 만들고 싶어합니다. 하지만 정적 사이트 생성기에서 SEO를 다룰 때는 일관성이 가장 중요합니다.
동적인 기본 URL을 사용해 정규화 태그를 만들려는 시도를 멈추고, 프로덕션 URL을 절대적인 진실로 강제했습니다.
1. “무차별” 정규화
lib/seo.ts에서 메타데이터 생성 로직을 수정해 도메인 이름에 대해 불안정한 빌드‑시간 변수를 절대 사용하지 않도록 했습니다.
// 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}`,
},
},
};
}
VERCEL_URL을 사용하지 않는 이유
VERCEL_URL은 동적입니다. 프리뷰 배포에서는 git-branch-project.vercel.app 같은 임시 도메인이 생성됩니다. 우리는 정규화 태그가 임시 프리뷰 도메인을 가리키는 것이 아니라, 빌드가 어디서 이루어지든 항상 실제 프로덕션 도메인을 가리키길 원합니다.
2. SEO를 서버 컴포넌트로 옮기기
Next.js 16을 사용하면서 SEO에 필요한 모든 데이터 페칭을 page.tsx 내부로 옮겼습니다. 이제 useEffect 같은 불필요한 로직이 없습니다.
SEO 문자열을 번역 파일(en.json, ja.json 등)에 저장해 두고, 서버 사이드에서 next‑intl을 이용해 슬러그에 맞는 번역을 동적으로 가져옵니다.
// 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
이제 모든 페이지가 절대적인 정규화 URL을 렌더링합니다. 빌드가 어디서 실행되든 https://www.chinasurvival.com을 정확히 가리키게 됩니다.
TL;DR
- 정규 URL에 런타임 환경 변수를 절대 사용하지 마세요. 프로덕션 도메인을 하드코딩하거나(또는 빌드 시점에 제어된 방식으로 주입) 하세요.
- 서버(App Router)에서 SEO 메타데이터를 생성하여 크롤러에 전달되는 HTML에 올바른 canonical,
hreflang, 그리고x‑default태그가 포함되도록 합니다. - 프로덕션에서 렌더링된
<link rel="canonical">를 확인하세요 – 절대 URL이어야 하며 Google이 크롤링할 수 있어야 합니다.
이러한 변경을 배포한 후, Google은 /en/guides/cities와 /ja/guides/cities 페이지를 하루 안에 다시 색인했으며, 사이트의 SEO 상태가 녹색으로 회복되었습니다. 🎉
이걸 await 하지 않으면 빌드가 실패합니다
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,
});
Next.js 15/16에 대한 참고 사항
최신 버전의 Next.js에서는 params와 searchParams가 비동기입니다.
params.slug에 바로 접근하고 await을 하지 않으면 런타임 오류가 발생합니다.
3. 사이트맵 전략
flatMap을 사용하도록 sitemap.ts를 업데이트했습니다. 이를 통해 Google이 영어 페이지에 도달하더라도 사이트맵이 일본어와 한국어 버전을 명확히 제공하도록 보장합니다.
// 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,
}));
});
}
사후 상황
수정을 배포하고 Google Search Console으로 돌아가 /en/guides/cities 페이지에서 **“Inspect URL”**을 클릭했습니다.
결과:
정규화된 URL이 최종적으로 https://www.chinasurvival.com/en/guides/cities 로 표시되었습니다.
“Request Indexing.” 을 클릭했습니다. 48시간 후, “Duplicate” 오류가 사라졌고, 도쿄에 있는 사용자들을 위한 검색 결과에 일본어 페이지가 나타나기 시작했습니다.
교훈
SEO 메타데이터와 관련해서는 암시보다 명시가 더 좋습니다. 빌드 환경이 URL을 추측하도록 믿지 마세요.
이 아키텍처는 China Survival Kit을 지원하기 위해 구축했으며, 여행자들이 Alipay, 현지 교통, 도시 가이드를 쉽게 이용할 수 있도록 돕는 도구입니다.
