Next.js Metadata 與 SEO

Next.js 對搜尋引擎最佳化 (SEO) 提供了開箱即用的強大支援。透過最新的 Metadata API,你可以輕鬆地為每個頁面定義標題、描述、關鍵字以及社群分享預覽圖(Open Graph)。

此外,Next.js 也提供了專門的檔案慣例 (File Conventions) 來自動生成 sitemap.xmlrobots.txt,這是現代 SEO 不可或缺的兩大支柱。

Metadata 設定

靜態 Metadata

對於內容固定的頁面(如首頁、關於我們),你只需要導出一個名為 metadata 的靜態物件。

// app/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Next.js 學習手冊',
  description: '從零開始掌握現代網頁開發技術',
};

export default function Page() {}

動態 Metadata

如果你的頁面內容是根據 URL 參數動態決定的(例如部落格文章詳情頁),你需要導出 generateMetadata 異步函式。

// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

type Props = {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
};

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  // 從 API 或資料庫獲取文章數據
  const slug = params.slug;
  const post = await fetch(`https://api.example.com/posts/${slug}`).then((res) => res.json());

  // 獲取父層級的 metadata (例如繼承的 Open Graph圖片)
  const previousImages = (await parent).openGraph?.images || [];

  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      images: [post.coverImage, ...previousImages],
    },
  };
}

Metadata 繼承與模板

Next.js 的 Metadata 具有層級關係:Page 層級會覆蓋 Layout 層級

為了保持全站標題格式一致(例如每個頁面都掛上 | 我的品牌),可以使用 title.template

// app/layout.tsx (Root Layout)
export const metadata = {
  title: {
    default: '我的超棒網站', // 當子頁面沒設定 title 時顯示
    template: '%s | 我的超棒網站', // 當子頁面有設定 title 時,填入 %s
  },
};

// app/about/page.tsx
export const metadata = {
  title: '關於我們', // 最終顯示: "關於我們 | 我的超棒網站"
};

Sitemap (網站地圖)

Sitemap 是告訴搜尋引擎你有哪些頁面需要被索引的重要檔案。Next.js 支援透過 app/sitemap.ts 自動生成。

基礎靜態 Sitemap

如果你的網站頁面不多且相對固定,可以直接回傳一個陣列。

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://acme.com',
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 1,
    },
    {
      url: 'https://acme.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: 'https://acme.com/blog',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.5,
    },
  ];
}

動態 Sitemap

對於部落格、電商網站,我們需要從資料庫撈取所有文章網址來動態生成。

// app/sitemap.ts
import { MetadataRoute } from 'next';

// 模擬從資料庫取得文章列表
async function getBlogPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getBlogPosts();

  // 1. 定義靜態頁面
  const staticRoutes = [
    {
      url: 'https://acme.com',
      lastModified: new Date(),
    },
    {
      url: 'https://acme.com/about',
      lastModified: new Date(),
    },
  ];

  // 2. 將文章列表轉換為 Sitemap 格式
  const dynamicRoutes = posts.map((post) => ({
    url: `https://acme.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.7,
  }));

  // 3. 合併回傳
  return [...staticRoutes, ...dynamicRoutes];
}

大型網站 Sitemap (分頁處理)

如果你的網址超過 50,000 個(Google 的單一 Sitemap 上限),你需要使用 generateSitemaps 來產生多個 Sitemap 檔案(例如 sitemap/1.xml, sitemap/2.xml)。

// app/sitemap.ts
import { MetadataRoute } from 'next';

// 假設我們有 10 萬篇文章,每頁 5 萬筆,共 2 頁
// ID 會是 0 和 1
export async function generateSitemaps() {
  // 回傳這網站共有幾個 sitemap ID
  return [{ id: 0 }, { id: 1 }];
}

export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
  // 根據 ID 去資料庫撈取對應範圍的文章 (Limit/Offset)
  const start = id * 50000;
  const posts = await getPostsFromDB(start, 50000);

  return posts.map((post) => ({
    url: `https://acme.com/blog/${post.id}`,
    lastModified: post.date,
  }));
}

Robots.txt

robots.txt 用來告訴搜尋引擎爬蟲哪些頁面可以抓取,哪些禁止進入。透過 app/robots.ts 可以動態生成。

常用情境範例

// app/robots.ts
import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  const baseUrl = 'https://acme.com';

  return {
    rules: {
      userAgent: '*', // 針對所有爬蟲
      allow: '/', // 允許爬取所有與頁面
      disallow: ['/admin/', '/private/'], // 禁止爬取代與頁面
    },
    sitemap: `${baseUrl}/sitemap.xml`, // 重要:告訴爬蟲 Sitemap 在哪
  };
}

區分正式站與測試站

你通常不希望 Google 索引你的測試環境 (Staging)。可以透過環境變數來動態調整規則。

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  // 判斷是否為正式環境
  const isProduction = process.env.VERCEL_ENV === 'production';

  if (!isProduction) {
    // 測試環境:完全禁止爬蟲
    return {
      rules: {
        userAgent: '*',
        disallow: '/',
      },
    };
  }

  // 正式環境:正常開放
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: '/api/',
    },
    sitemap: 'https://acme.com/sitemap.xml',
  };
}

Open Graph (OG) 圖片

社群分享時顯示的預覽圖對點擊率影響巨大。Next.js 提供了兩種方式:

  1. 靜態檔案:直接在路由資料夾放 opengraph-image.pngtwitter-image.png
  2. 動態生成:使用 ImageResponse API。

使用 ImageResponse 動態生成

這非常適合「文章標題不同,圖片文字就不同」的需求。建立 opengraph-image.tsx

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug);

  return new ImageResponse(
    <div
      style={{
        fontSize: 48,
        background: 'white',
        width: '100%',
        height: '100%',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      {post.title}
    </div>,
    {
      width: 1200,
      height: 630,
    }
  );
}

小結

  • Metadata:務必設定 title, description 與 Open Graph,這對 SEO 是基本功。
  • Sitemap:使用 app/sitemap.ts 自動對映你的路由,大型網站需記得做分頁。
  • Robots.txt:使用 app/robots.ts 來防止測試站被索引,並明確指出 Sitemap 位置。
  • 自動化:Next.js 的這些 API 都是基於程式碼生成的,這意味著你的 SEO 設定會隨著資料庫內容自動更新,無需人工維護。