為什麼 Google 搜不到我的文章:Next.js personal-site 的四個 SEO 漏洞
per-page metadata 缺失、metadataBase 跟 sitemap 指不同 domain、沒 JSON-LD、沒提交 GSC——四個漏洞一次修。
文章發出去快一天,Google site: 搜尋完全沒結果。一篇剛上線的新站不容易被收錄是常識,但我沒料到打開 view-source 看 head 一眼,問題不只「還沒被爬」這一個。
每篇文章 <title> 都一樣、metadataBase 跟 sitemap 指向不同 domain、沒有 structured data、沒提交 Google Search Console——四個漏洞同時存在。即便 Google 真的爬到,搜尋結果也只會看到站名,不是文章標題。
這篇記錄四個漏洞各自的成因、修法,以及驗收方式。
漏洞一:每篇文章共用同一個 <title>
打開任何一篇 note 的 view-source:
<title>Coco</title>
<meta name="description" content="Frontend engineer, perpetual builder...">
不管 URL 是 /notes/web-performance-deep-dive 還是 /notes/react-markdown-remark-gfm-trap,head 裡的 title 跟 description 都是同一份——root layout 的站名與 tagline。
Next.js App Router 的 metadata 是繼承制:dynamic route(app/notes/[slug]/page.tsx)如果沒寫 generateMetadata,就直接 fallback 到上層的 metadata object。我把站名寫在 app/layout.tsx,每篇文章 page 沒覆寫,於是全站共用。
對 Google 來說,這代表四篇文章「看起來都是同一頁」——搜尋結果裡顯示的不會是文章標題,而是站名。SERP 點擊率直接歸零。
修法:dynamic route 補 generateMetadata,從資料源取出 per-slug 的 title / description / canonical。
// app/notes/[slug]/page.tsx
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const note = SEED_NOTES.find(n => n.slug === slug);
if (!note) return { title: slug };
return {
title: note.title, // 自動套用 layout 的 title.template
description: note.excerpt,
keywords: note.tags,
alternates: { canonical: `${SITE_URL}/notes/${slug}` },
openGraph: { /* ... */ },
};
}
搭配 root layout 加 title.template:
// app/layout.tsx
title: {
default: `${SITE.name} — ${SITE.fullName}`,
template: `%s — ${SITE.name}`, // 子頁 title 自動拼站名
},
build 後 out/notes/<slug>.html 的 <title> 就會是「文章標題 — Coco」。
漏洞二:metadataBase 跟 sitemap 指向不同 domain
// app/layout.tsx
metadataBase: new URL('https://coco-personal-site.web.app'),
// app/sitemap.ts
const BASE_URL = 'https://personal-site-mocha-chi.vercel.app';
Firebase Hosting 跟 Vercel 兩個 domain 同時存在,且互相不知道對方。og:url、canonical、絕對 URL 全部是 web.app;sitemap.xml 列的卻是 vercel.app。
對 Google 來說,這是兩個獨立的網站。Crawl signals、backlinks、index coverage 全部一分為二,誰也不會贏。Canonical confusion 是 SEO 最容易自殘的方式之一。
修法:選一個 canonical domain,全部對齊。
// app/layout.tsx
const SITE_URL = "https://personal-site-mocha-chi.vercel.app";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
alternates: { canonical: "/" }, // 站根 canonical
openGraph: { url: SITE_URL, /* ... */ },
};
alternates.canonical: "/" 會搭配 metadataBase 自動展開成完整 URL。每個 note page 的 generateMetadata 也要回傳自己的 canonical(見漏洞一)。
漏洞三:沒有 Article JSON-LD
view-source 從頭到尾找不到 <script type="application/ld+json">。沒 structured data 就沒 rich result。搜尋結果頂多顯示 title + description,沒有作者、發布日期、tags 這些可以提升點擊率的視覺元素。
Article schema 在 Next 16 的 server component 裡用 inline script 直接渲染:
// app/notes/[slug]/page.tsx
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: note.title,
description: note.excerpt,
url,
datePublished: note.date.replace(/\./g, "-"),
author: { "@type": "Person", name: SITE.fullName, url: SITE_URL },
publisher: { "@type": "Person", name: SITE.fullName, url: SITE_URL },
mainEntityOfPage: { "@type": "WebPage", "@id": url },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<NoteDetailClient slug={slug} initialBody={localBody} />
</>
);
output: 'export' 的 static export 模式下,這個 script 會被序列化進 HTML,crawler 拿得到。修完用 Google Rich Results Test 貼 URL 進去驗證 Article schema 被偵測到。
漏洞四:沒提交 Google Search Console
*.vercel.app 子域 + 0 backlinks + 全新站——這個組合 Google 主動爬到的機率趨近於零。新站不主動提交 GSC,等自然發現可能要幾週甚至幾個月。
修法:透過 GSC 的 HTML tag 驗證。Next 提供 metadata.verification.google API,把 verification code 透過 env var 注入:
// app/layout.tsx
verification: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION
? { google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION }
: undefined,
Vercel CLI 一行加好 production env var:
echo "_ZsWMD2fU9x5y6p..." | vercel env add NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION production
vercel --prod --force --yes # --force 跳過 build cache,確保新 env 套用
build 後 <head> 出現:
<meta name="google-site-verification" content="_ZsWMD2fU9x5y6p...">
回 GSC 按 Verify,再 Sitemaps → 提交 sitemap.xml。8 個 URLs(4 篇 notes + home/projects/notes/cv)全部進 indexing queue。
驗收:三條 curl 一次驗完
部署完後跑這三條:
# 1. per-page metadata 上線
curl -s https://personal-site-mocha-chi.vercel.app/notes/<slug> \
| grep -oE '<title>[^<]*</title>|<link rel="canonical"[^>]*>'
# 2. JSON-LD 序列化進 HTML
curl -s https://personal-site-mocha-chi.vercel.app/notes/<slug> \
| grep -c 'application/ld+json'
# 3. GSC verification meta tag 出現
curl -s https://personal-site-mocha-chi.vercel.app/ \
| grep -o '<meta name="google-site-verification"[^>]*>'
三條都有輸出,加上 sitemap.xml 回 200,就確認 SEO infra 全部到位。剩下就是等 Google 排程——首批文章被收錄通常 1-7 天,個別文章標題出現在自然搜尋結果預估 2-4 週。
為什麼這四個會同時發生
不是巧合。模板專案常見的預設組合:站名寫在 root layout、單一 og:url、generateStaticParams 沒搭配 generateMetadata、開發階段 deploy 多個 domain 試手感、structured data 跟 GSC 屬於「上線後再補」的工作項目。
每一條單獨看都不嚴重,但合在一起的後果是 SEO infra 從零開始——即便 content 寫得再好,搜尋引擎要嘛找不到、要嘛找到也不知道是什麼。
總結
| 漏洞 | 影響 | 修法 |
|---|---|---|
共用 <title> | SERP 顯示站名而非文章標題,CTR 歸零 | dynamic route 補 generateMetadata + root layout title.template |
| metadataBase 跟 sitemap 不同 domain | Crawl signals 一分為二,canonical confusion | 選一個 canonical domain,alternates.canonical 對齊 |
| 沒 Article JSON-LD | 失去 rich result eligibility | server component 內 inline <script type="application/ld+json"> |
| 沒提交 GSC | 新站等自然發現可能數月 | verification.google via env var + vercel env add + vercel --prod --force |