Notes
← All notes
·frontend·7 min

為什麼 Google 搜不到我的文章:Next.js personal-site 的四個 SEO 漏洞

per-page metadata 缺失、metadataBase 跟 sitemap 指不同 domain、沒 JSON-LD、沒提交 GSC——四個漏洞一次修。

#Next.js#SEO#Metadata#JSON-LD

文章發出去快一天,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 不同 domainCrawl signals 一分為二,canonical confusion選一個 canonical domain,alternates.canonical 對齊
沒 Article JSON-LD失去 rich result eligibilityserver component 內 inline <script type="application/ld+json">
沒提交 GSC新站等自然發現可能數月verification.google via env var + vercel env add + vercel --prod --force