← ブログ一覧

Next.js App Router で押さえたい SEO の基本

Metadata API・静的生成・OGP をどう組み合わせるか、実務目線で短くまとめました。

#Next.js#SEO#技術解説
Next.js App Router で押さえたい SEO の基本

はじめに:App Router で SEO はどう変わったか

Next.js 13 以降の App Router は、従来の Pages Router と比較して SEO 周りの実装方法が大きく変わりました。next/head を使っていた時代から、Metadata API による宣言的なメタデータ管理へと移行しています。

本記事では、App Router を使った実務プロジェクトで押さえておくべき SEO の基本を、コード例付きで解説します。


1. Metadata API の基本

静的メタデータ

ページごとに固定の title・description を設定する場合は、metadata オブジェクトを export します。

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

export const metadata: Metadata = {
  title: "会社概要 | サービス名",
  description: "私たちは〇〇を提供する会社です。創業からの歩みとビジョンを紹介します。",
  openGraph: {
    title: "会社概要 | サービス名",
    description: "私たちは〇〇を提供する会社です。",
    type: "website",
  },
};

export default function AboutPage() {
  return <main>...</main>;
}

動的メタデータ

ブログ記事や商品ページなど、データに基づいてメタデータを生成する場合は generateMetadata 関数を使います。

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  
  if (!post) {
    return { title: "記事が見つかりません" };
  }

  return {
    title: `${post.title} | ブログ`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      authors: [post.author],
    },
  };
}

metadataBase の設定

OGP 画像などで絶対 URL が必要な場合、各ページで毎回フル URL を書くのは面倒です。ルートの layout.tsxmetadataBase を設定しておくと、相対パスが自動的に絶対 URL に変換されます。

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  // 以下、各ページで /ogp.jpg と書けば https://example.com/ogp.jpg に展開される
};

2. ページごとの SEO 最適化ポイント

title タグのベストプラクティス

  • ユニーク: 全ページで異なる title を設定する
  • 簡潔: 30〜60 文字程度が理想(Google の検索結果で途切れない範囲)
  • キーワード配置: 重要なキーワードは前半に置く
  • ブランド名: 末尾に「| サービス名」を付けるパターンが一般的
// 良い例
title: "React Hooks 入門ガイド | TechBlog";

// 悪い例(長すぎる)
title: "React Hooks の useState, useEffect, useContext, useReducer を徹底解説する完全ガイド | TechBlog";

description の書き方

  • 120〜160 文字程度: 長すぎると検索結果で途切れる
  • 行動喚起: 「〜を解説します」「〜が分かります」など、読者のメリットを明示
  • ユニーク: 全ページで異なる description を設定する(同じテンプレートの使い回しは NG)
// 良い例
description: "Next.js App Router の Metadata API を使った SEO 実装を解説。静的生成・動的メタデータ・OGP 設定のコード例付き。";

// 悪い例(一般的すぎる)
description: "技術ブログです。様々な記事を掲載しています。";

3. 静的生成(SSG)と SEO

generateStaticParams

ブログや商品一覧など、URL が事前に決まっているページは、ビルド時に HTML を生成しておくと、クローラに対して高速にレスポンスを返せます。

// app/blog/[slug]/page.tsx
import { getAllPostSlugs } from "@/lib/blog";

export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

// これにより、/blog/post-1, /blog/post-2 などがビルド時に生成される

ISR(Incremental Static Regeneration)

完全な静的生成が難しい場合(頻繁に更新されるデータなど)は、revalidate を設定して定期的に再生成します。

// 60 秒ごとに再生成
export const revalidate = 60;

// または fetch 単位で設定
const data = await fetch(url, { next: { revalidate: 60 } });

静的生成 vs SSR:SEO 観点での選択基準

| 方式 | クローラへの応答速度 | データの新鮮さ | 推奨ケース | |------|---------------------|----------------|------------| | SSG | 最速(CDN から配信) | ビルド時の状態 | ブログ、LP、ドキュメント | | ISR | 高速(初回以降キャッシュ) | revalidate 間隔で更新 | 更新頻度が中程度のページ | | SSR | やや遅い(毎回サーバー処理) | 常に最新 | ユーザー固有データ、リアルタイム性が必要な場合 |


4. OGP(Open Graph Protocol)の設定

基本的な OGP 設定

SNS でシェアされた際の見え方を制御する OGP は、SEO とは直接関係ありませんが、流入経路として重要です。

export const metadata: Metadata = {
  openGraph: {
    title: "記事タイトル",
    description: "記事の説明",
    type: "article",
    url: "https://example.com/blog/post-1",
    images: [
      {
        url: "/ogp/post-1.png", // metadataBase があれば相対パスで OK
        width: 1200,
        height: 630,
        alt: "記事のサムネイル",
      },
    ],
    siteName: "サービス名",
    locale: "ja_JP",
  },
  twitter: {
    card: "summary_large_image",
    title: "記事タイトル",
    description: "記事の説明",
    images: ["/ogp/post-1.png"],
  },
};

OGP 画像のサイズ

  • 推奨サイズ: 1200 x 630 px(1.91:1 のアスペクト比)
  • ファイル形式: PNG または JPEG
  • ファイルサイズ: 1MB 以下を推奨

動的 OGP 画像の生成

Next.js では ImageResponse を使って動的に OGP 画像を生成できます。

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/blog";

export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

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

  return new ImageResponse(
    (
      <div style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        width: "100%",
        height: "100%",
        backgroundColor: "#0f172a",
        color: "#fff",
        padding: 60,
      }}>
        <h1 style={{ fontSize: 48, fontWeight: 700 }}>{post?.title}</h1>
        <p style={{ fontSize: 24, color: "#94a3b8" }}>サービス名</p>
      </div>
    ),
    { ...size }
  );
}

5. 構造化データ(JSON-LD)

検索結果でリッチスニペット(評価、パンくずリスト、FAQ など)を表示するには、構造化データを追加します。

記事ページの構造化データ例

// app/blog/[slug]/page.tsx
import Script from "next/script";

export default async function BlogPost({ params }: Props) {
  const post = await getPostBySlug(params.slug);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      "@type": "Person",
      name: post.author,
    },
    publisher: {
      "@type": "Organization",
      name: "サービス名",
      logo: {
        "@type": "ImageObject",
        url: "https://example.com/logo.png",
      },
    },
  };

  return (
    <>
      <Script
        id="json-ld"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>...</article>
    </>
  );
}

6. sitemap.xml と robots.txt

sitemap.xml の生成

App Router では app/sitemap.ts を作成することで自動生成できます。

// app/sitemap.ts
import { MetadataRoute } from "next";
import { getAllPostSlugs } from "@/lib/blog";

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

  const blogEntries = posts.map((slug) => ({
    url: `https://example.com/blog/${slug}`,
    lastModified: new Date(),
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  return [
    { url: "https://example.com", lastModified: new Date(), priority: 1.0 },
    { url: "https://example.com/about", lastModified: new Date(), priority: 0.8 },
    ...blogEntries,
  ];
}

robots.txt

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

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/admin/", "/api/"],
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}

7. よくある SEO の落とし穴

Client Component での metadata

"use client" を付けたコンポーネントでは metadata を export できません。メタデータはサーバーコンポーネントで管理し、クライアントコンポーネントはその中で使用する形にしましょう。

重複コンテンツ

  • www あり/なしの統一: どちらかにリダイレクト
  • トレイリングスラッシュの統一: /about/about/ が両方存在しないようにする
  • canonical タグ: 同じ内容が複数 URL で存在する場合は canonical で正規 URL を指定
export const metadata: Metadata = {
  alternates: {
    canonical: "/blog/post-1",
  },
};

JavaScript レンダリングへの依存

クローラは JavaScript を実行しますが、レンダリングに時間がかかるとインデックスされにくくなります。重要なコンテンツは SSG/SSR で初期 HTML に含めましょう。


まとめ

| やるべきこと | 実装場所 | |-------------|----------| | ページ固有の title/description | metadata または generateMetadata | | metadataBase の設定 | ルートの layout.tsx | | OGP 画像 | openGraph.images または opengraph-image.tsx | | 静的生成 | generateStaticParams | | 構造化データ | JSON-LD を <Script> で埋め込み | | sitemap/robots | app/sitemap.ts, app/robots.ts |

SEO は技術的な土台を整えた上で、ユーザーの検索意図に応える高品質なコンテンツを積み上げていくことが本質です。本記事のコード例を参考に、まずは基本を固めてください。


Next.js を使った Web サイト・アプリ開発のご相談は、お問い合わせからお気軽にどうぞ。

この内容について相談する他の記事を見る