2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsを利用したウェブアプリケーションにSEO/LLMOを施す

2
Last updated at Posted at 2026-04-30

はじめに

本記事は私が Next.js を利用して個人開発しているスマホでラップ音楽を作成できるウェブアプリ Scrhyme において、現時点でコードベースに入っている 検索エンジン向け SEOAI 検索・引用(LLMO / GEO / AIO と呼ばれる領域) のために施した実装を整理したものです。
最後に同様の技術構成(Next.js, TypeScript)でSEO実装を行う方向けにチェックリストを記載しています。

お知らせ(採用情報)

AppTime では一緒に働くメンバーを募集しております。
詳しくは採用情報ページをご確認ください。

みなさまからのご応募をお待ちしております。

記事の流れ

  1. SEOとは … SEO と実装の役割の整理
  2. 用語と仕組み … canonical / OG / robots / sitemap / JSON-LD など
  3. 設計方針・前提 … なぜ共通化するか、スタック
  4. Schema.org … JSON-LD で使う語彙
  5. 具体的実装・チェックリスト

クロールから検索・引用までの関係をざっくり示すと次のとおりです。

頻出の略語

  • LLMO(Large Language Model Optimization): 生成 AI やチャットが参照・要約しやすいようにコンテンツやメタ情報を整える、という文脈でよく使われます。
  • GEO(Generative Engine Optimization): AI が生成する回答や要約に引用されやすくする、という議論で使われることがあります。
  • AIO(AI Optimization): 検索以外の AI 露出全般をひっくるめた総称として、ここでは便宜的に使っています。

従来の検索エンジン向け SEOとAI 時代の発見・引用の両方に触れる、という理解で読んでください。

SEOとは

SEO(Search Engine Optimization、検索エンジン最適化)とは、Google や Bing などの検索サービスにおいて、自サイトのページが、ユーザーが調べたい内容に関連するときに見つかりやすくなり、検索結果一覧でもタイトルや説明文が意図どおり伝わるように整えるための取り組みの総称です。

検索エンジンは、インターネット上のページを クローラ が巡回し、内容を把握したうえで インデックス(検索用の索引)に載せ、クエリに応じて結果を並べます。

SEO でよく扱うのは、この流れのうち 「正しく巡回・解釈されること」「各ページが何について書かれているかが機械にも伝わること」 を技術面から支える部分です。

具体的には、ページごとの titledescription、重複 URL を整理する canonical、クロール範囲を示す robots.txt、探索の手がかりとなる サイトマップ、意味を明示する 構造化データ(JSON-LD) などが該当します。

また SEO は 検索順位を必ず上げるわけではありません。アルゴリズムは更新され、表示や順位は必ずしもコントロールできません。一方で、ユーザーにとって読みやすく信頼できる本文と、検索エンジンがページの役割を誤解しない実装を両立させることは、長期的に見ても合理的です。
本記事では主に後者の Next.js(App Router)側の実装パターンに焦点を当てます。

用語と仕組み

ここでは、以降の章で登場する用語を 「なぜ必要か」「どう表現するか」「Next.js App Router ではどう書くか」 の観点で短く整理します。

canonical(カノニカル URL)

  • なぜ必要か: 同じ内容が httphttps、末尾スラッシュの有無、?utm=... 付き URL など 複数のアドレスで開けると、検索エンジンは「どれが正式な1ページか」を迷います。評価が分散したり、意図しない URL が検索結果に出たりします。「この URL を正規として扱ってほしい」と伝えるのが canonical です。
  • どう表現するか: HTML では <link rel="canonical" href="https://example.com/path">。検索エンジンはこれを強いヒントとして扱います。
  • Next.js では: metadataalternates.canonical、または共通ヘルパーで オリジン + パス から絶対 URL を組み立てて渡します。

Open Graph(OG)・Twitter Card

  • なぜ必要か: SNS やチャットアプリでリンクを貼ったときに表示される「タイトル・説明・画像」を整えるためです。検索結果用の title / description と似ていますが、別仕様(OG プロトコル)なので、通常は両方そろえます。
  • どう表現するか: <meta property="og:title" ...> のような OG タグ、Twitter 用は twitter:card など。
  • Next.js では: metadataopenGraphtwitter にオブジェクトで指定すると、ビルド時・SSR 時に適切な <meta> が出力されます。

実装例: canonical・Open Graph・Twitter

オリジンが取れるとき、alternates.canonical に絶対 URL を入れ、同じ urlopenGraph / twitter と共有しています(オリジン不明時は title / description のみ)。

このコードの要点: 1 関数で canonical・OG・Twitter をそろえ、Scrhyme では全公開ページからこれを経由させています。

export function buildPublicPageMetadata({
  title,
  description,
  path,
  imageUrl,
}: PublicPageMetaInput): Metadata {
  const origin = getSiteOrigin();
  const url =
    origin === undefined
      ? undefined
      : path === "/"
        ? `${origin}/`
        : `${origin}${path.startsWith("/") ? path : `/${path}`}`;
  const base: Metadata = { title, description };

  if (url === undefined) {
    return base;
  }

  return {
    ...base,
    alternates: { canonical: url },
    openGraph: {
      title,
      description,
      url,
      siteName: "Scrhyme",
      locale: "en_US",
      type: "website",
      ...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
      ...(imageUrl ? { images: [imageUrl] } : {}),
    },
  };
}

補足: summary_large_image は Twitter(現 X)でリンクを共有したときに表示されるカード種別のひとつで、サムネイルではなく 大きめの画像付きで表示されやすくなります。card type をそろえると、シェア時の見た目がブレにくくなります。

robots.txt

  • なぜ必要か: サイト全体に対して「どのパスをクローラに読ませる/読ませないか」の目安を置くためのテキストファイルです。
    特定のページを検索結果に表示したくないときは noindex(後述のメタやヘッダ)を使います。
  • どう表現するか: サイトのルートに https://example.com/robots.txt として置き、User-agentAllow / DisallowSitemap: の行で書きます。
  • Next.js では: app/robots.ts で関数が MetadataRoute.Robots を返すと、/robots.txt として配信されます。環境ごとに中身を変える(本番だけ許可、プレビューは全面 disallow など)のに向いています。

実装例(frontend/app/robots.ts

このコードの要点: 本番以外は丸ごと Disallow: / でステージングのインデックス事故を防ぎ(vercelに課金している場合ステージング環境ではサイトに自動的に認証がかかるため必要ないと思いますが念のため)、本番だけプロフィール編集画面やボーカルレコーディング画面といったツール系のパスを disallow しつつ sitemap の URL を明示します。

import type { MetadataRoute } from "next";

import { getSiteOrigin } from "@/lib/site-url";

export default function robots(): MetadataRoute.Robots {
  const origin = getSiteOrigin();

  // 本番以外(preview/staging/local)はインデックス事故を避ける
  if (process.env.NODE_ENV !== "production" || origin === undefined) {
    return {
      rules: [{ userAgent: "*", disallow: "/" }],
    };
  }

  return {
    rules: [
      {
        userAgent: "*",
        disallow: [
          "/api/",
          "/record",
          "/post",
          "/profile",
          "/sign-in",
          "/sign-up",
        ],
      },
    ],
    sitemap: `${origin}/sitemap.xml`,
  };
}

sitemap.xml

  • なぜ必要か: 公開してよい URL の一覧と、更新頻度や最終更新のヒントを検索エンジンに伝えます。すべての URL を載せる必要はなく、見つけてほしいページを優先的に伝える用途です。robots.txt から Sitemap: で場所を示すのが一般的です。
  • どう表現するか: XML 形式のファイル(複数に分割することもあります)。
  • Next.js では: app/sitemap.ts が配列を返すと /sitemap.xml になります。動的に DB や CMS から URL を足すのに適しています。

実装例(frontend/app/sitemap.ts)抜粋

本番ホストと一致するときだけエントリを返すガード、revalidate = 3600、Sanity の記事 URL、Prisma で取得した UGC のスコア順・件数上限を経て items を組み立てます。
低品質のコンテンツが検索エンジンに多く渡るとサイト全体のSEO評価を落としかねないため、ユーザーが投稿したコンテンツ(UGC)に関しては他ユーザーから一定以上のエンゲージメント(ライク・コメント)を受けた投稿のみsitemapに追加する処理を施しています。

このコードの要点: 想定ドメインからのアクセスだけ URL を列挙し、ブログは CMS、UGC はスコアで絞り込んだうえで items を組み立てています。

priority は sitemap.xml 内の各 URL に対して、そのページを 相対的にどれくらい重要(優先度) とみなすかを 0.0〜1.0 の範囲で指定するための値です(厳密な順位を決めるものではなく、優先度の目安)。この実装ではトップや主要フィードなど重要度が高い URL ほど大きい値にし、UGC は相対的に低めに設定しています。

export const revalidate = 3600;

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const h = await headers();
  const hostRaw = (h.get("x-forwarded-host") ?? h.get("host") ?? "").toLowerCase();
  const host = hostRaw.replace(/:\d+$/, "");
  const proto = (h.get("x-forwarded-proto") ?? "https").toLowerCase();
  const requestOrigin = host ? `${proto}://${host}` : "";

  const origin = "https://www.scrhyme.com";
  if (requestOrigin !== origin) return [];

  const client = getPublicSanityClient();
  const posts = await client.fetch<SitemapPostRow[]>(sitemapPostsQuery);

  const ugcRows = await prisma.post.findMany({
    where: { isPublic: true, deletedAt: null },
    orderBy: { createdAt: "desc" },
    take: UGC_CANDIDATE_LIMIT,
    select: {
      slug: true,
      createdAt: true,
      updatedAt: true,
      user: { select: { urlSlug: true } },
      _count: { select: { likes: true, comments: true } },
    },
  });

  const sortedUgc = [...ugcRows].sort((a, b) => {
    const scoreA = computeRankingScore(a._count.likes, a._count.comments);
    const scoreB = computeRankingScore(b._count.likes, b._count.comments);
    if (scoreB !== scoreA) return scoreB - scoreA;
    return b.createdAt.getTime() - a.createdAt.getTime();
  });

  const strongUgc =
    sortedUgc.filter(
      (row) => computeRankingScore(row._count.likes, row._count.comments) > 0,
    ).length > 0
      ? sortedUgc
          .filter(
            (row) =>
              computeRankingScore(row._count.likes, row._count.comments) > 0,
          )
          .slice(0, MAX_STRONG_UGC_URLS)
      : sortedUgc.slice(0, RANKING_TOP_LIMIT);

  const items: MetadataRoute.Sitemap = [
    { url: `${origin}/`, changeFrequency: "weekly", priority: 1 },
    { url: `${origin}/feed`, changeFrequency: "daily", priority: 0.9 },
    {
      url: `${origin}/policies`,
      changeFrequency: "monthly" as const,
      priority: 0.3,
    },
    {
      url: `${origin}/feed/new`,
      changeFrequency: "daily" as const,
      priority: 0.85,
    },
    {
      url: `${origin}/feed/top`,
      changeFrequency: "daily" as const,
      priority: 0.85,
    },
    {
      url: `${origin}/ranking`,
      changeFrequency: "daily" as const,
      priority: 0.8,
    },
    { url: `${origin}/blog`, changeFrequency: "daily" as const, priority: 0.7 },
    ...posts.map((p) => ({
      url: `${origin}/blog/${p.slug}`,
      lastModified: new Date(p.publishedAt),
      changeFrequency: "monthly" as const,
      priority: 0.6,
    })),
    ...strongUgc
      .filter((row) => row.user.urlSlug.trim().length > 0 && row.slug.length > 0)
      .map((row) => ({
        url: `${origin}/${row.user.urlSlug}/${row.slug}`,
        lastModified: row.updatedAt ?? row.createdAt,
        changeFrequency: "weekly" as const,
        priority: 0.5,
      })),
  ];

  return items;
}

JSON-LD

  • なぜ必要か: ページの内容を 機械が読みやすい形(「これは記事で、タイトルは〇〇、公開日は△△」など)で添えるためのデータ記述形式です。ファイルではなく、HTML 内に埋め込む JSON を指します。検索エンジンの リッチリザルト(パンくず、記事の著者表示など)や、近年では 要約・AI 回答の参照にも効く場合があります。
  • どう表現するか: <script type="application/ld+json"> の中に JSON を書き、 vocabulary は後述の Schema.org に従うのが一般的です。

実装例: ルートレイアウトの WebSitedangerouslySetInnerHTMLfrontend/app/layout.tsx

JSON-LD を <script type="application/ld+json"> に載せる際、React では次のように dangerouslySetInnerHTML でJSON を渡しています(オブジェクトはサーバー側で組み立て)。

このコードの要点: オリジンが取れるときだけ WebSite 型の JSON-LD を 1 本出し、サイト全体の「正規のトップ URL」を宣言します。

const origin = getSiteOrigin();
const webSiteJsonLd =
  origin === undefined
    ? null
    : {
        "@context": "https://schema.org",
        "@type": "WebSite",
        name: "Scrhyme",
        url: origin,
      };

return (
  <html lang="en">
    <body>
      {webSiteJsonLd !== null && (
        <script
          type="application/ld+json"
          // eslint-disable-next-line react/no-danger
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(webSiteJsonLd),
          }}
        />
      )}
      {children}
    </body>
  </html>
);

(実ファイルでは 認証に用いるClerkProvider や他コンポーネントが挟まっています。)

dangerouslySetInnerHTML

  • 名前の由来: React はデフォルトで XSS(クロスサイトスクリプティング) を防ぐため、文字列をそのまま HTML として挿入しません。dangerouslyで注意喚起を促した上で意図的に HTML を差し込むときだけ dangerouslySetInnerHTML を使います。
  • JSON-LD で使う理由: <script type="application/ld+json">中身を React で組み立てるとき、プレーンな子テキストとして渡す方法の一つが dangerouslySetInnerHTML={{ __html: JSON.stringify(...) }} です。
  • どう安全に使うか: サーバー側で組み立てたオブジェクトJSON.stringify するだけなら、ユーザー入力をそのまま混ぜない限りリスクは低いです。ユーザー生成コンテンツをそのまま JSON-LD に入れる場合は エスケープ・検証が別途必要になります。

設計方針:なぜ一元管理するのか

canonical、Open Graph、Twitter Card、robots、sitemap、構造化データ(JSON-LD)をページごとにバラバラに書くと、表記ゆれ・環境差・メンテ忘れが起きやすくなります。Scrhyme では次の方針にそろえています。

  • サイトの絶対 URLは一か所で決め、メタ・JSON-LD・サイトマップが同じ前提を共有する。
  • 公開ページ用メタは共通ビルダーへ集約し、title / description に加えて canonical・OG・Twitter を揃える。
  • インデックス不要な画面は別ヘルパーで noindex を明示し、robots.txt の disallow と役割分担する。

以下、Next.js の機能名は App Router 前提で書きます。

前提:スタックと対象

  • フレームワーク: Next.js(App Router)+ TypeScript
  • CMS: Sanity
  • その他: 認証・ミドルウェア等は本稿では SEO 周辺に触れる範囲に限定

UI 文言はプロダクト方針で英語に統一しています。

Schema.orgとは

Schema.org は、Google・Microsoft・Yahoo などが後ろ盾となる共通の「語彙(ボキャブラリー)」です。Web 上の「これはブログ記事」「これは製品」「これは組織」といった 種類(型)と、その型が持てる プロパティheadlinedatePublished など)を schema.org 上で定義しています。

  • 目的: 同じ意味のデータを、サイトごとに別名で表さず、検索エンジンやツールが解釈しやすいようにすることです。
  • JSON-LD との関係: Schema.org の型名とプロパティを使って JSON を書き、<script type="application/ld+json"> で載せるのが、いま広く使われるパターンです。
  • 保証ではない: マークアップをしても 必ずリッチリザルトになるわけではありません。検索エンジンの判断次第ですが、「意味を明示する材料」として価値があります。

Scrhyme で使っている型の対応関係

ここでは どのページで、何を表すかだけ示します。

Schema.org 型(概要) 出力するページの例 役割
WebSite ルート layout サイト名とトップレベルの URL。サイト全体の「存在」を宣言。
BlogPosting ブログ記事 /blog/[slug] 記事タイトル、説明、canonical、公開・更新日、著者、画像など。記事ページとしての意味を明示。
MusicRecording UGC (ユーザーが生成したコンテンツ) /{username}/{slug} 曲タイトル、公開日、作者(ユーザー)、publisher(サービス運営者)など。ユーザー投稿の音源としての意味を明示(出力条件は品質・公開フラグと連動)。

実装パターン(概念)

  • どこに書くか: ルートレイアウトではサイト全体の JSON-LD を1つ。記事・UGC など ページ固有の意味は、その page.tsx 内でオブジェクトを組み立て、dangerouslySetInnerHTML<script type="application/ld+json"> に埋め込む、という二層になりがちです。
  • **@context@type**: JSON-LD では "@context": "https://schema.org"@type(例: BlogPosting)で「Schema.org の語彙を使う」と宣言します。

実装例: BlogPostingfrontend/app/blog/[slug]/page.tsx)抜粋

このコードの要点: 記事ページの canonical と対応する BlogPosting を組み立て、著者・publisher・日付・画像を Schema.org 形式で渡します。

const jsonLd =
  canonicalUrl === undefined
    ? null
    : {
        "@context": "https://schema.org",
        "@type": "BlogPosting",
        headline: post.seoTitle?.trim() || post.title,
        description:
          post.seoDescription?.trim() ||
          post.excerpt?.trim() ||
          "Read the latest guides and updates from Scrhyme.",
        mainEntityOfPage: {
          "@type": "WebPage",
          "@id": canonicalUrl,
        },
        url: canonicalUrl,
        datePublished: new Date(post.publishedAt).toISOString(),
        dateModified: new Date(post._updatedAt ?? post.publishedAt).toISOString(),
        author: post.author?.name
          ? { "@type": "Person", name: post.author.name }
          : { "@type": "Organization", name: "Scrhyme" },
        publisher: {
          "@type": "Organization",
          name: "Scrhyme",
          url: origin,
          logo: {
            "@type": "ImageObject",
            url: `${origin}/assets/icons/ScrhymemLogo.png`,
          },
        },
        ...(ogImageUrl ? { image: [ogImageUrl] } : {}),
      };

return (
  <>
    {jsonLd !== null && (
      <script
        type="application/ld+json"
        // eslint-disable-next-line react/no-danger
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
    )}
    {/* ... 本文 ... */}
  </>
);

実装例: MusicRecordingfrontend/app/[username]/[slug]/page.tsx)抜粋

const jsonLd =
  canonicalUrl === undefined
    ? null
    : {
        "@context": "https://schema.org",
        "@type": "MusicRecording",
        name: post.title,
        url: canonicalUrl,
        datePublished: post.createdAt.toISOString(),
        dateModified: post.updatedAt.toISOString(),
        author: {
          "@type": "Person",
          name: user.username,
          ...(profileUrl ? { url: profileUrl } : {}),
        },
        publisher: {
          "@type": "Organization",
          name: "Scrhyme",
          url: origin,
        },
        ...(user.imageUrl ? { image: user.imageUrl } : {}),
        isAccessibleForFree: true,
      };

WebSite は前述「JSON-LD の実装例」(ルート layout.tsx)を参照してください。


1. 全体アーキテクチャ

役割 ファイル名 内容
オリジン解決 lib/site-url.ts 環境変数(例: NEXT_PUBLIC_SITE_ORIGIN)やホストの正規化。
公開ページメタの共通化 lib/build-page-metadata.ts buildPublicPageMetadata で title / description に加え、オリジンが取れるとき canonicalOpen GraphTwitter Card を付与。任意で OG 画像 URL。
非インデックス 同上 buildNoIndexPageMetadatarobots: { index: false, follow: true }
ルートレイアウト app/layout.tsx デフォルト metadata<html lang="en">、オリジンが取れるときだけ WebSite 型の JSON-LD を <script type="application/ld+json"> で出力。

オリジン未設定のローカル等では、絶対 URL が不要なら canonical / OG の絶対 URL を付けないというフォールバックにすると誤った URL を検索エンジンに渡しにくくなります。(前述の通りVercelにデプロイする場合課金していればステージング環境で自動的に認証が設定されるため必要ないと思いますが)

実装例: オリジン解決(frontend/lib/site-url.ts

export function getSiteOrigin(): string | undefined {
  const raw =
    process.env.NEXT_PUBLIC_SITE_ORIGIN?.trim() ??
    process.env.NEXT_PUBLIC_APP_URL?.trim();

  const normalizedFromRaw = raw ? normalizeOrigin(raw) : undefined;
  if (normalizedFromRaw) return normalizedFromRaw;

  const vercel = process.env.VERCEL_URL?.trim();
  if (vercel) {
    return normalizeOrigin(vercel);
  }

  return undefined;
}

function normalizeOrigin(input: string): string | undefined {
  const candidate = input.trim();
  if (!candidate) return undefined;

  const withScheme = /^https?:\/\//i.test(candidate)
    ? candidate
    : `https://${candidate}`;

  try {
    const url = new URL(withScheme);
    const host =
      url.hostname === "scrhyme.com" ? "www.scrhyme.com" : url.hostname;
    return `https://${host}`.replace(/\/$/, "");
  } catch {
    return undefined;
  }
}

実装例: 非インデックス用メタ(frontend/lib/build-page-metadata.ts

export function buildNoIndexPageMetadata({
  title,
  description,
}: ToolPageMetaInput): Metadata {
  return {
    title,
    description,
    robots: { index: false, follow: true },
  };
}

実装例: ルートのデフォルト metadatafrontend/app/layout.tsx)抜粋

サイト全体の既定タイトル・説明・アイコンをここで定義しています(各ページの metadata が上書き可能)。

export const metadata: Metadata = {
  title: "Scrhyme - The simplest way to make your own song.",
  description:
    "Pick a beat, record your flow, and get finished tracks with pro-level effects. No studio or music theory required — create and share directly from your phone.",
  icons: {
    icon: "/assets/icons/ScrhymeLogo.png",
    apple: "/apple-touch-icon.png",
    other: {
      rel: "icon",
      url: "/assets/icons/ScrhymeLogo.png",
    },
  },
};

なお、SEO の一環として画像を軽量な WebP にすることがありますが、アイコン周りは互換性の都合で注意が必要です。Next.js の公式ドキュメント(favicon / icon / apple-icon の “Image files” 規約)では対応するファイル拡張子が明確に決まっており、 Webp は含まれていませんfavicon.ico のみ / icon.ico.jpg.jpeg.png.svg / apple-icon.jpg.jpeg.png)。

したがって、推奨の画像形式は次のとおりです。

  • favicon: .ico
  • icon: .ico / .png / .jpg / .jpeg / .svg
  • apple-icon(iOS のホーム画面に出るアイコン相当): .png / .jpg / .jpeg

参考: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons

2. Next.js(App Router)の機能

2.1 静的ルートの metadata

export const metadata: Metadata = { ... } または共通ビルダーの戻り値をそのまま export。ランディング、フィード一覧、ポリシーなど URL が固定で、サーバーで追加フェッチが不要なページ向け。

実装例: トップ(frontend/app/page.tsx

export const metadata: Metadata = buildPublicPageMetadata({
  title: "Scrhyme — The simplest way to make your own song.",
  description:
    "Pick a beat, record your flow, and get finished tracks with pro-level effects. No studio or music theory required — create and share directly from your phone.",
  path: "/",
});

実装例: 投稿一覧(frontend/app/feed/page.tsx

export const metadata: Metadata = buildPublicPageMetadata({
  title: "Feed",
  description:
    "Discover public rap tracks from the Scrhyme community — listen, like, and comment.",
  path: "/feed",
});

実装例: ポリシー(frontend/app/policies/page.tsx

export const metadata: Metadata = buildPublicPageMetadata({
  title: "Policies — Scrhyme",
  description:
    "Scrhyme policies for user-generated content, privacy, terms, and copyright.",
  path: "/policies",
});

2.2 動的ルートの generateMetadata

app/blog/[slug]/page.tsxapp/[username]/[slug]/page.tsx のように パスパラメータに応じて内容が変わるページでは、generateMetadata でデータ取得 → 404 なら専用タイトル → それ以外は buildPublicPageMetadata を拡張、という流れが一般的です。

Scrhyme での例(要点のみ):

  • ブログ記事: Sanity から取得した seoTitle / seoDescription / 抜粋へのフォールバック。OG は seoImage → メイン画像 → サイト共通 OGP の順。openGraph.typearticle。本文側に BlogPosting JSON-LD。
  • ブログカテゴリ: カテゴリ slug に応じたタイトル・説明文を generateMetadata で組み立て。
  • UGC 共有 URL: ユーザー名・投稿 slug ごとにタイトル・説明(歌詞先頭のトリム等)。公開でも タイトル長・歌詞長・音声 URL などで品質不足なら noindex。条件を満たす場合に MusicRecording JSON-LD。

コードの書き方自体は Metadata の公式ドキュメント に沿いました。

実装例: ブログ記事(frontend/app/blog/[slug]/page.tsxgenerateMetadata 抜粋

このコードの要点: CMS の SEO フィールドと画像フォールバックを反映したうえで、openGraph.typearticle にしてシェア表示を記事向けに切り替えます。

export async function generateMetadata({
  params,
}: BlogPostPageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPublishedBlogPostBySlug(slug);
  if (!post) {
    return buildPublicPageMetadata({
      title: "Blog post not found",
      description: "This article does not exist.",
      path: `/blog/${slug}`,
    });
  }

  const description =
    post.seoDescription?.trim() ||
    post.excerpt?.trim() ||
    "Read the latest guides and updates from Scrhyme.";
  const path = `/blog/${post.slug.current}`;
  const base = buildPublicPageMetadata({
    title: `${post.seoTitle?.trim() || post.title} — Scrhyme`,
    description,
    path,
    imageUrl: toAbsoluteUrl("/assets/og/ScrhymeOGP.png"),
  });

  const ogImageUrl = getOgImageUrl({
    mainImageUrl:
      post.seoImage?.asset?.url ?? post.mainImage?.asset?.url ?? null,
  });

  return {
    ...base,
    openGraph: {
      ...(base.openGraph ?? {}),
      type: "article",
      images: ogImageUrl ? [{ url: ogImageUrl }] : undefined,
    },
    twitter: {
      ...(base.twitter ?? {}),
      images: ogImageUrl ? [ogImageUrl] : undefined,
    },
  };
}

実装例: ブログカテゴリ(frontend/app/blog/category/[slug]/page.tsx

export async function generateMetadata({
  params,
}: BlogCategoryPageProps): Promise<Metadata> {
  const { slug } = await params;
  const category = await getBlogCategoryBySlug(slug);
  const title = category?.title
    ? `${category.title} — Scrhyme Blog`
    : "Blog category — Scrhyme Blog";
  const description =
    category?.description?.trim() ||
    "Browse Scrhyme Blog posts by category.";

  return buildPublicPageMetadata({
    title,
    description,
    path: `/blog/category/${slug}`,
    imageUrl: toAbsoluteUrl("/assets/og/ScrhymeOGP.png"),
  });
}

2.3 app/robots.ts と app/sitemap.ts(Metadata Routes)

  • robots.ts: MetadataRoute.Robots を返す。本番かつオリジンが決まるときだけクロール許可+sitemap URL を記載。プレビューやローカルでは **Disallow: /** でインデックス事故を防ぐ、という二段構えにしています。
  • sitemap.ts: MetadataRoute.Sitemap を返す。静的ハブ URL、CMS の各記事 URL、UGC は スコアや件数上限で抽出するなど、量と品質のバランスをサイトマップ側で調整。

→ コード全文は「用語と仕組み」の robots.txt / sitemap.xml の実装例を参照してください。

2.4 JSON-LD(構造化データ)

JSON-LD・dangerouslySetInnerHTML・Schema.org の前提は、上記「用語と仕組み」と「Schema.org とは」にまとめています。実装では、layout や各ページでオブジェクトを組み立て、dangerouslySetInnerHTML<script type="application/ld+json"> に載せます。リッチリザルトに加え、AIO 的に「この URL が何についてか」を補強する目的もあります。型は Schema.orgWebSite / BlogPosting / MusicRecording など、ページ種別に合わせます。

3. クロール制御と発見(robots / sitemap)の意図

robots.txt とサイトマップの役割の違いは、冒頭の「用語と仕組み」で触れています。
ここでは Scrhyme 固有の方針に絞ります。

実装コードは「用語と仕組み」の frontend/app/robots.ts / frontend/app/sitemap.ts を参照してください。

  • robots.txt: API・録音・投稿編集・認証・プロフィール編集など ツール系パスは disallow。意図と重複する画面は ページ側 noindex と組み合わせる。
  • sitemap.xml: ブログは lastModified に公開日等を反映。ユーザーが生成したコンテンツは一定以上のエンゲージメントを集めた投稿だけを載せるなど、クローラに認知させる URLを選別しています(前述の通り低品質のページが検索エンジンに多く渡るとサイト全体の評価を落としかねないため)

4. ページタイプ別:インデックスとメタの方針(一覧)

種類 メタデータ 補足
ランディング 静的 metadata + 共通ビルダー FAQ は details/summary で初期 HTML に全文。id="faq"。About 相当に id="about"(トップページ内アンカー、おそらく個別ページを設けた方が良いですがまだ開発段階であるため簡易的に実装しました)。
投稿一覧・ビート・ランキング 静的、ページごとにユニークな title/description ハブとしての役割を説明文で区別。
ブログ一覧・カテゴリ・記事 記事・カテゴリは generateMetadata 記事は article OG + BlogPosting。
UGC /{user}/{slug} generateMetadata + 条件付き noindex 品質ゲート + MusicRecording(条件付き)。
ポリシー 静的 /policies に Content / Privacy / Terms 等を集約。信頼性・一次情報の固定 URL。
録音・認証など buildNoIndexPageMetadata + robots disallow クローラー向けに公開しない。

実装例: 録音ページの noindex(frontend/app/record/page.tsx

export const metadata: Metadata = buildNoIndexPageMetadata({
  title: "Record vocal",
  description:
    "Record your vocal over a beat with smartphone on Scrhyme.",
});

5. CMS(Sanity)側の SEO フィールド

ブログスキーマに seoTitle / seoDescription / seoImage を用意し、未設定時はタイトル・抜粋・メイン画像にフォールバックする運用をフロントに実装します。
タイトルや抜粋、メイン画像をそのまま使う方が素直ですがコンテンツSEOの観点からは独自のインフォグラフィックがある方が評価が高くなるようなのでYAGNI原則に反しますが実装しておきました。

実装例(studio/schemaTypes/post.ts)抜粋

defineField({
  name: "seoTitle",
  title: "SEO title",
  type: "string",
  description: "Overrides the default title for search/OG. Leave blank to use Title.",
}),
defineField({
  name: "seoDescription",
  title: "SEO description",
  type: "text",
  rows: 3,
  description:
    "Overrides the default description for search/OG. Leave blank to use Excerpt.",
}),
defineField({
  name: "seoImage",
  title: "SEO image",
  type: "image",
  options: {
    hotspot: true,
  },
  description:
    "Overrides the default OG image. Leave blank to use Main image, otherwise fallback to site OGP.",
}),

6. OG 画像とソーシャルプレビュー

実装上のポイントは:

  • サイト共通 OGP 画像を public 配下に置き、ブログで個別画像がない場合のフォールバックに使う。
  • 共通ビルダーで Twitter を summary_large_image にそろえると、画像が大きめに表示されるカードになるため、カード表示の挙動が予測しやすくなります。

実装例: ブログの OG 画像フォールバック(frontend/app/blog/[slug]/page.tsx

function getOgImageUrl(input: { mainImageUrl?: string | null }): string | undefined {
  if (input.mainImageUrl && input.mainImageUrl.trim().length > 0) {
    return input.mainImageUrl;
  }
  return toAbsoluteUrl("/assets/og/ScrhymeOGP.png");
}

generateMetadata 内で seoImagemainImage

7. LLM / AI 検索向けにやっていること

検索エンジン以外にも、エージェントや要約が参照しやすいように次を組み合わせています。

施策 内容
**/llms.txt** public/llms.txt にサービス概要、重要 URL 一覧、アンカー(/#about, /#faq)、引用・帰属の一文。慣習的なファイルで、必須ではありませんが置いておくと意図が伝わりやすいです。
FAQ / About トップページにセマンティックな HTML で配置。
Policies プラポリやコンテンツ作成時のルールといったサイト運営ポリシーを単一 URL に。サイトマップにも含める。
フッター Discover / Create / Learn & Legal などグループで内部リンク。

実装例: llms.txtfrontend/public/llms.txt

静的ファイルとしてルートに公開されます(https://<ドメイン>/llms.txt)。

Scrhyme — The simplest way to make your own song.

Scrhyme lets you pick a beat, record your flow, and automatically get pitch-corrected vocals with pro-level effects — all from your smartphone.

## Important pages
- Home: /
- About (on home page): /#about
- FAQ (on home page): /#faq
- Feed: /feed
- Top feed: /feed/top
- New feed: /feed/new
- Rankings: /ranking
- Blog: /blog
- Policies: /policies

## Content & attribution
- Please attribute quotes to “Scrhyme” and link to the source URL.
- User-generated tracks in the feed are created by users; individual posts may be removed or updated.

実装例: フッターの内部リンク(frontend/components/footer.tsx)抜粋

const FOOTER_LINK_GROUPS: readonly FooterLinkGroup[] = [
  {
    title: "Discover",
    links: [
      { href: "/feed", label: "Feed" },
      { href: "/ranking", label: "Ranking" },
    ],
  },
  {
    title: "Create",
    links: [{ href: "/beats", label: "Beats" }],
  },
  {
    title: "Learn & Legal",
    links: [
      { href: "/blog", label: "Blog" },
      { href: "/policies", label: "Policies" },
    ],
  },
];

まとめ

  • canonical / OG / robots / sitemap / JSON-LD はそれぞれ役割が異なるので、混同しない(初学者向けの整理は「用語と仕組み」参照)。
  • Schema.org は「意味の共通語彙」であり、JSON-LD と組み合わせて検索エンジンやツールにページの種類を伝える。
  • オリジンとメタデータの共通化で、環境ごとのブレを減らす。
  • robots / sitemap は「本番だけ許可」「ツール系は閉じる」「サイトマップは質と量を管理」。
  • 動的ルートgenerateMetadata でデータ駆動にし、ブログは article + BlogPosting、UGC は 品質ゲート + MusicRecording のようにページ種別で役割を分ける。
  • AIO 補助llms.txt、FAQ、ポリシー、内部リンクで「読み取りやすい一次情報」を積み上げる。

Next.js エンジニア向け SEO 実装チェックリスト(10項目)

  1. インデックスさせたいページに必ず title / description を付ける(静的→ metadata 、動的→ generateMetadata で役割分担)
  2. canonical を絶対 URL で出すalternates.canonical。origin が取れない環境では誤出力しない方針にする)
  3. Open Graph / Twitter Card を揃えるopenGraphtwitter を共通ビルダー経由に寄せ、画像は絶対 URL(https://...)で渡す)
  4. インデックスさせない画面は二段構えにするrobots.ts の disallow に加え、ページ側 robots: { index: false } を明示)
  5. sitemap.xml は「公開してよい・見つけてもらいたい URL だけ」出す(投稿の公開・非公開を選択できるアプリの場合非公開の投稿は含めず、一定以上のエンゲージメントを集めたコンテンツを選び、全件ではなく品質の高い集合のみ抽出する。出す頻度は revalidate でコントロールする)
  6. 構造化データは Schema.org の型で書く@context: "https://schema.org"@type をページ種別に合わせる)
  7. JSON-LD の出力条件を制御する(canonical がない場合は出さない、画像や著者はフォールバック)
  8. dangerouslySetInnerHTML はサーバー側で組み立てた JSON の stringify に限定する(未検証のユーザー入力を入れない)
  9. 主要コンテンツをサーバーで完結させる(SEO 用本文が hydration 待ちにならないようにする)
  10. リリース後に検証する/robots.txt/sitemap.xml・Rich Results Test・Search Console)

参考


公開情報として 汎用的なパターンのみ記載しています。
環境変数名やパスはプロジェクトごとに読み替えてください。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?