Next.js Metadata 與 SEO

Next.js Metadata API 完整指南 — generateMetadata、sitemap/robots、OG image、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.tsxlayout.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 結果

generateMetadatapage.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>;
}

細節見 rendering-and-cache.md


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,預覽卡片沒圖

可能原因

  1. URL 不是絕對路徑(必須 https://...
  2. 圖片需公開可存取(不能要登入)
  3. 圖片小於 200x200 會被忽略
  4. 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 失敗

可能原因

  1. sitemap.ts 內部 fetch 上游失敗
  2. 超過 50,000 URL 沒分組

解決方案

  1. 加 try-catch + fallback 空陣列
  2. 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
  • 每頁有獨特 titledescription
  • 每頁有 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

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章