目錄
- 什麼是 Metadata API?
- 靜態 metadata 物件
- 動態 generateMetadata
- Layout 與 Page 的合併規則
- OpenGraph 與 Twitter Card
- opengraph-image.tsx 動態產圖
- sitemap.ts / robots.ts / manifest.ts
- 結構化資料(JSON-LD)
- canonical 與 alternates
- 實戰範例
- 常見問題
- 總結
- 參考資源
什麼是 Metadata API?
Metadata API 是 Next.js App Router 提供的「在 server 端宣告 HTML <head> 內容」機制。它涵蓋:
<title><meta name="description">- OpenGraph 系列(社群分享預覽)
- Twitter Card
- canonical URL
- 多語系 alternates
- robots 指示
為什麼需要 Metadata API?
傳統做法的問題:
// 純 React SPA 通常用 next/head 或 react-helmet
<Head>
<title>商品 - 雙肩背包</title>
<meta name="description" content="..." />
</Head>
- 客戶端動態插入 → 爬蟲拿到的可能是空殼
- 沒有型別保護,欄位寫錯不會警告
- OpenGraph 等社群預覽需要 server-rendered 才生效
Metadata API 的解法:
- ✅ 完整型別安全(
Metadata型別) - ✅ Server-rendered,爬蟲與社群預覽都拿得到
- ✅ Layout 階層自動繼承與覆寫
- ✅ 內建動態 OG image 產生器
靜態 metadata 物件
最簡單的用法:在 page.tsx 或 layout.tsx export 一個 metadata 物件。
// app/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "首頁",
description: "我的網站首頁",
keywords: ["關鍵字1", "關鍵字2"],
};
export default function HomePage() {
return <h1>歡迎</h1>;
}
常用欄位
export const metadata: Metadata = {
// 基本
title: "標題",
description: "描述",
keywords: ["keyword1", "keyword2"],
authors: [{ name: "作者名", url: "https://example.com" }],
// OpenGraph
openGraph: {
title: "OG 標題",
description: "OG 描述",
url: "https://example.com/page",
siteName: "網站名",
images: [
{
url: "https://example.com/og.png",
width: 1200,
height: 630,
alt: "預覽圖",
},
],
locale: "zh_TW",
type: "website",
},
// Twitter
twitter: {
card: "summary_large_image",
title: "Twitter 標題",
description: "Twitter 描述",
images: ["https://example.com/twitter.png"],
creator: "@username",
},
// 結構與索引
alternates: {
canonical: "/page",
languages: {
"en-US": "/en/page",
"zh-TW": "/page",
},
},
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true },
},
// 圖示
icons: {
icon: "/favicon.ico",
apple: "/apple-touch-icon.png",
},
};
metadataBase
在 root layout 設定 metadataBase 後,所有相對路徑的 URL 都會自動補完:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
// 之後子頁面寫 alternates.canonical: "/about"
// 會自動展開成 https://example.com/about
};
沒設
metadataBase時,相對 URL 會給 warning 並 fallback 到 localhost。
動態 generateMetadata
當 metadata 需要根據 params / searchParams / 上游資料變化時,用 generateMetadata 函式:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://cms.example.com/posts/${slug}`).then((r) =>
r.json()
);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
// ...
}
與 Page 共用 fetch 結果
generateMetadata 跟 page.tsx 內呼叫同樣的 fetch 會被 Request Memoization 去重 — 不會打兩次 API。
async function getPost(slug: string) {
return fetch(`https://cms.example.com/posts/${slug}`).then((r) => r.json());
// 同一次 request 內,呼叫多次只實際打一次
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title };
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug); // 自動 dedupe
return <article>{post.content}</article>;
}
Layout 與 Page 的合併規則
當多層 layout 與 page 都宣告 metadata 時,Next.js 會從 root 往下合併:
app/
├── layout.tsx ← metadata A
├── blog/
│ ├── layout.tsx ← metadata B(會 merge A)
│ └── [slug]/
│ └── page.tsx ← metadata C(會 merge B)
合併規則
| 欄位類型 | 合併方式 |
|---|---|
title |
覆寫(page 優先) |
description |
覆寫 |
openGraph |
物件級覆寫(整個 OG 區塊被替換) |
keywords |
覆寫(不是合併陣列) |
alternates.canonical |
覆寫 |
Title Template
title 可以用 template 模式,讓子頁繼承格式但塞自己標題:
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: "%s | 我的網站",
default: "我的網站", // 子頁沒設 title 時用 default
},
};
// app/about/page.tsx
export const metadata: Metadata = {
title: "關於我們", // 最終 = "關於我們 | 我的網站"
};
// app/page.tsx (不設 title)
// 最終 = "我的網站"
title.absolute 可以跳過 template:
export const metadata: Metadata = {
title: { absolute: "首頁" }, // 最終 = "首頁"
};
OpenGraph 與 Twitter Card
OpenGraph 是什麼?
Facebook、LinkedIn、Discord 等社群分享連結時,會去拉 <meta property="og:*"> 標籤產生預覽卡片。
完整 OpenGraph 設定
export const metadata: Metadata = {
openGraph: {
title: "分享預覽標題",
description: "分享預覽描述",
url: "https://example.com/page", // 此頁絕對 URL
siteName: "網站名稱",
images: [
{
url: "https://example.com/og.png", // 1200x630 最佳
width: 1200,
height: 630,
alt: "預覽圖描述",
},
],
locale: "zh_TW",
type: "website", // 或 "article", "book", "profile" 等
},
};
Article 類型
文章類頁面用 type: "article" 多帶幾個欄位:
openGraph: {
type: "article",
publishedTime: "2026-05-16T00:00:00Z",
modifiedTime: "2026-05-16T00:00:00Z",
authors: ["https://example.com/authors/john"],
tags: ["Next.js", "React"],
}
Twitter Card
Twitter 用獨立一套 meta:
export const metadata: Metadata = {
twitter: {
card: "summary_large_image", // 或 "summary", "player", "app"
title: "Twitter 預覽標題",
description: "Twitter 預覽描述",
images: ["https://example.com/twitter.png"], // 與 OG 圖可不同
creator: "@username",
site: "@sitename",
},
};
opengraph-image.tsx 動態產圖
Next.js 內建 ImageResponse API,可在 build / runtime 動態產生 OG 圖片,用 JSX 寫排版。
站台層級 OG image
新建 app/opengraph-image.tsx:
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OpengraphImage() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(to bottom right, #4f46e5, #ec4899)",
color: "white",
fontSize: 72,
}}
>
我的網站
</div>
),
{ ...size }
);
}
Next.js 會自動把這張圖掛到該層 + 子層的 <meta property="og:image">。
動態 OG image(per-page)
針對 blog/[slug] 動態產生每篇文章的 OG:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OG({
params,
}: {
params: { slug: string };
}) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`).then(
(r) => r.json()
);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
padding: 80,
background: "white",
}}
>
<h1 style={{ fontSize: 72, color: "#111" }}>{post.title}</h1>
<p style={{ fontSize: 32, color: "#666", marginTop: 40 }}>
{post.author} · {new Date(post.date).toLocaleDateString()}
</p>
</div>
),
{ ...size }
);
}
twitter-image.tsx
也可以建立 twitter-image.tsx,邏輯同上。
sitemap.ts / robots.ts / manifest.ts
sitemap.ts
讓 Google 知道網站有哪些 URL。動態產生:
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetch("https://cms.example.com/posts").then((r) =>
r.json()
);
return [
{
url: "https://example.com",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
{
url: "https://example.com/about",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
...posts.map((post: { slug: string; updatedAt: string }) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "weekly" as const,
priority: 0.7,
})),
];
}
訪問 /sitemap.xml 自動產出 XML。
大網站分組 sitemap
// app/sitemap.ts
export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }, { id: 2 }];
}
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
const start = id * 50000;
const end = start + 50000;
const posts = await fetchPosts({ start, end });
return posts.map((p) => ({ url: `https://example.com/blog/${p.slug}` }));
}
訪問 /sitemap/0.xml、/sitemap/1.xml 等。
robots.ts
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/"],
},
{
userAgent: "Googlebot",
allow: ["/"],
disallow: ["/api/"],
},
],
sitemap: "https://example.com/sitemap.xml",
};
}
manifest.ts
PWA 支援,產生 /manifest.webmanifest:
// app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "我的應用",
short_name: "App",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#000",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
},
],
};
}
結構化資料(JSON-LD)
Google 用 JSON-LD 理解頁面內容,產生「rich snippets」(評分、價格、麵包屑等)。
文章類
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{post.content}</article>
</>
);
}
商品類
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.image,
description: product.description,
brand: { "@type": "Brand", name: product.brand },
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: "TWD",
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
};
FAQ 類
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer,
},
})),
};
抽出共用元件
// components/JsonLd.tsx
type Props = { data: Record<string, unknown> };
export default function JsonLd({ data }: Props) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
// 使用
<JsonLd data={articleSchema} />
通用 SEO 觀念請看 web/seo-guide.md。本篇聚焦 Next.js 怎麼實作。
canonical 與 alternates
canonical 防重複內容
當同一篇內容可能被多個 URL 存取時(searchParams、tracking 參數),用 canonical 告訴 Google「真正的位址」:
export const metadata: Metadata = {
alternates: {
canonical: "/blog/hello-world", // 自動加上 metadataBase
},
};
多語系 alternates
告訴 Google 同一頁的其他語言版本:
export const metadata: Metadata = {
alternates: {
canonical: "/page",
languages: {
"en-US": "/en/page",
"zh-TW": "/page",
"ja-JP": "/ja/page",
"x-default": "/page",
},
},
};
Pagination 的 prev / next
export const metadata: Metadata = {
other: {
"next-page": "/blog?page=3",
"prev-page": "/blog?page=1",
},
};
或寫 <link rel="prev" /> 在 head(透過 raw JSX)。
實戰範例
範例 1:部落格完整 SEO 套組
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import JsonLd from "@/components/JsonLd";
type Props = { params: Promise<{ slug: string }> };
async function getPost(slug: string) {
const res = await fetch(`https://cms.example.com/posts/${slug}`);
if (!res.ok) return null;
return res.json();
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.authorUrl],
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { "@type": "Person", name: post.author },
};
return (
<>
<JsonLd data={jsonLd} />
<article>
<h1>{post.title}</h1>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
範例 2:商品頁 + 動態 OG image
// app/products/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export default async function OG({ params }: { params: { id: string } }) {
const product = await fetch(`/api/products/${params.id}`).then((r) => r.json());
return new ImageResponse(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
background: "white",
padding: 80,
}}
>
<img
src={product.image}
width={400}
height={400}
alt={product.name}
/>
<div style={{ marginLeft: 60, display: "flex", flexDirection: "column" }}>
<h1 style={{ fontSize: 64 }}>{product.name}</h1>
<p style={{ fontSize: 48, color: "#e11d48", marginTop: 20 }}>
NT$ {product.price}
</p>
</div>
</div>
),
{ ...size }
);
}
常見問題
問題 1:OG image 在 Facebook 沒顯示
症狀:分享連結到 Facebook,預覽卡片沒圖
可能原因:
- URL 不是絕對路徑(必須
https://...) - 圖片需公開可存取(不能要登入)
- 圖片小於 200x200 會被忽略
- Facebook 有 cache,用 Sharing Debugger 重抓
解決方案:
openGraph: {
images: [{
url: "https://example.com/og.png", // 絕對路徑
width: 1200,
height: 630,
}],
}
問題 2:metadata 沒套用
症狀:page 設了 metadata 但 view source 看不到
可能原因:用了 "use client" 在 page.tsx 上
規則:
- ✅
metadata只能在 Server Component 內 export - ❌ Client Component (
"use client") 不支援
解決方案:把 metadata 移到 layout(永遠 server)或拆出一層 server wrapper
問題 3:generateMetadata 報錯說 fetch 沒帶 protocol
症狀:Failed to parse URL
原因:沒設 metadataBase 又用了相對路徑
解決方案:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
};
問題 4:sitemap.xml 500 錯誤
症狀:訪問 /sitemap.xml 失敗
可能原因:
sitemap.ts內部 fetch 上游失敗- 超過 50,000 URL 沒分組
解決方案:
- 加 try-catch + fallback 空陣列
- 用
generateSitemaps分組
問題 5:robots.txt 顯示舊內容
原因:CDN / 瀏覽器快取
解決方案:強制重抓(Vercel 部署完後幾分鐘會自動 invalidate)
總結
核心要點
Metadata API = 靜態 metadata 物件 + 動態 generateMetadata + 特殊檔名(sitemap/robots/og-image)
SEO 三層架構:
- 頁面標題與描述 →
metadata/generateMetadata - 社群分享預覽 → OpenGraph / Twitter / opengraph-image.tsx
- 爬蟲指引 → robots.ts / sitemap.ts / canonical / alternates
- 結構化資料 → JSON-LD
<script>
必做檢查清單
-
metadataBase設在 root layout - 每頁有獨特
title與description - 每頁有 OG image(站台預設 + 動態 per-page)
-
sitemap.ts列出所有公開 URL -
robots.ts禁止/api/、/admin/ - 重點頁面有 JSON-LD(Article / Product / FAQ / Organization)
- 動態頁面有
alternates.canonical
特殊檔名速查
| 檔名 | 路徑 | 自動產出 URL |
|---|---|---|
app/sitemap.ts |
站台層級 | /sitemap.xml |
app/robots.ts |
站台層級 | /robots.txt |
app/manifest.ts |
站台層級 | /manifest.webmanifest |
app/opengraph-image.tsx |
站台層級 | /opengraph-image |
app/blog/[slug]/opengraph-image.tsx |
per-page | /blog/xxx/opengraph-image |
app/favicon.ico |
站台層級 | /favicon.ico |
相關閱讀
- Next.js 入門指南 — App Router 基礎
- 渲染與快取 — generateMetadata 的 fetch 也吃 4 層快取
- SEO 完全指南 — 框架無關的通用 SEO 觀念
- 部署與 Runtime — OG image 用 Edge Runtime 速度更快
參考資源
- 官方 Metadata 文件:https://nextjs.org/docs/app/api-reference/functions/generate-metadata
- Metadata Files:https://nextjs.org/docs/app/api-reference/file-conventions/metadata
- Schema.org 結構化資料:https://schema.org/docs/full.html
- Google Rich Results 測試:https://search.google.com/test/rich-results
- Facebook Sharing Debugger:https://developers.facebook.com/tools/debug/
- Twitter Card Validator:https://cards-dev.twitter.com/validator
建立日期:2026-05-16 最後更新:2026-05-16