記事やパンくずのJSON-LDの記述をコンポーネント化した

サイトやサービスを扱っている兼ね合いで、JSON-LDを埋めることがよくある。特にNext.jsを使って構築することが多いので、そのあたりも踏まえて覚書をしておく

コンポーネント化した対象

  • WebSite
  • Breadcrumbs
  • Article

事前準備

ReactでJSON-LDを扱ううえで、提供されている型を使えると色々と楽になるので、 schema-dts パッケージを入れておく

https://www.npmjs.com/package/schema-dts

$ npm i -D schema-dts

基本的にはこれに則って定義すればOKだと思う。ただ、必須定義がGoogle側のバリデーターと合わないことがあるので、サイトに反映後、Search Consoleを見ながら適宜調整が必要に思う

ベースになるコンポーネントの実装

また、JSON-LDを生成する部分は同じ構造になるはずなので、ベースコンポーネントを作っておく。

import type { ReactElement } from 'react';
import type { Thing, WithContext } from 'schema-dts';

type JsonLdProps<T extends Thing = Thing> = {
  data: WithContext<T>;
  id?: string;
};

export default function JsonLd({ data, id }: JsonLdProps): ReactElement {
  const safeJson = JSON.stringify(data).replace(/</g, '\\u003c'); // HTMLが入ってくる場合に備えて閉じタグをエスケープして安全に出力する

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: safeJson }}
      data-testid={id}
    />
  );
}

dataの型は一応適応されていました。
ThingはArticleやWebSiteなどの具体の型が入ってきます。

WebSite

これが特別ありがたいではないんだけど、おまじない的に入れている。

最終的には以下が生成される。(scriptタグは省略)

{
  "@context":"https://schema.org",
  "@type":"WebSite",
  "name":"MemoDrip",
  "url":"https://www.memodrip.net",
  "description":"毎日のドリップをより良いものに"
}

これに向けて次のようなコンポーネントを作る

import type { WithContext, WebSite } from 'schema-dts';

const websiteJsonLd: WithContext<WebSite> = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  name: SITE.name,
  url: SITE.url,
  description: SITE.description,
} as const;

export default function WebsiteJsonLd() {
  return <JsonLd data={websiteJsonLd} id={'jsonld-website'} />;
}

Siteはサイト内で共通して出力したいので、app/layout.tsx に実装する。(Next.js(AppRouter)の場合)

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ja">
      <head>
        <WebsiteJsonLd />
      </head>
    :
  )
}

Breadcrumbs

リッチリザルトを期待する声をよく耳にするけれど、個人的にはページの階層構造をより理解してもらいやすくすることでより上手くクロールしてもらう為に入れるみたいなニュアンスで考えている。

最終的な出力イメージはこれ

{
  "@context":"https://schema.org",
  "@type":"BreadcrumbList",
  "itemListElement":[
    {
      "@type":"ListItem",
      "position":1,
      "name":"トップ",
      "item":"https://www.memodrip.net/"
    },
    {
      "@type":"ListItem",
      "position":2,
      "name":"コーヒー一覧"
     }
  ]
}

その為、以下のようなコンポーネントを実装する。

import type { WithContext, BreadcrumbList } from 'schema-dts';

export const generateBreadcrumbJsonLd = (
  items: BreadcrumbsProps['items']
): WithContext<BreadcrumbList> => {
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, index) => {
      const href =
        typeof item.href === 'string'
          ? item.href
          : item.href
            ? `${item.href.pathname ?? ''}${item.href.query ? `?${new URLSearchParams(item.href.query as Record<string, string>).toString()}` : ''}`
            : undefined;

      const url = href ? new URL(href, SITE.url).toString() : undefined;

      return {
        '@type': 'ListItem' as const,
        position: index + 1,
        name: item.label,
        ...(url && { item: url }),
      };
    }),
  };
};

export default function BreadcrumbsJsonLd({ items }: BreadcrumbsProps) {
  const jsonLd = generateBreadcrumbJsonLd(items);

  return <JsonLd data={jsonLd} id="breadcrumbs-jsonld" />;
}

利便性なんかも考えて、パンくずのコンポーネントに組み込むのが運用もしやすくて良いと考えている。その為 BreadcrumbsProps はパンくずのコンポーネントで、以下のようなPropsにしている。

export type BreadcrumbsProps = {
  items: BreadcrumbItem[];
};

type BreadcrumbItem = {
  label: string;
  href?: string | UrlObject;
};

Article

コンポーネントとしては、外からArticle情報を渡すようにして次のようにしてみた。

/**
 * 読了時間を推定する(日本語400文字/分ベース)
 */
function estimateReadingTime(text: string): string {
  const cleanText = text.replace(/<[^>]*>/g, "").trim();
  const minutes = Math.ceil(cleanText.length / 400);
  return `PT${minutes}M`; // ISO 8601形式(例: PT5M = 5分)
}

/**
 * ブログ記事のJSON-LD構造化データを生成する
 */
function generateBlogPostJsonLd(post: IPost, slug: string): WithContext<BlogPosting> {
  // 記事の本文からdescriptionを生成(HTMLタグを除去し、最初の160文字を使用)
  const description =
    post.body
      .replace(/<[^>]*>/g, "") // HTMLタグを除去
      .replace(/\s+/g, " ") // 連続する空白を一つにまとめる
      .trim()
      .slice(0, 160) + (post.body.replace(/<[^>]*>/g, "").length > 160 ? "..." : "");

  // 画像URLの決定(サムネイルがある場合はそれを使用、なければOG画像生成)
  const imageUrl =
    post.thumbnail?.url ||
    `${SITE.url}${ROUTE.ogImage}?${new URLSearchParams({ title: post.title }).toString()}`;

  // 記事のURL
  const articleUrl = `${SITE.url}${ROUTE.postDetail(slug)}`;

  return {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description,
    image: imageUrl,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      "@type": "Person",
      name: SITE.author.name,
      url: SITE.author.url,
    },
    publisher: {
      "@type": "Organization",
      name: SITE.name,
      url: SITE.url,
    },
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": articleUrl,
    },
    url: articleUrl,
    wordCount: post.body.replace(/<[^>]*>/g, "").length,
    keywords: post.tags.map((tag) => tag.name),
    articleSection: post.category.name,
    timeRequired: estimateReadingTime(post.body),
  };
}

interface BlogPostJsonLdProps {
  post: IPost;
  slug: string;
}

/**
 * ブログ記事のJSON-LD構造化データを出力するコンポーネント
 */
export function BlogPostJsonLd({ post, slug }: BlogPostJsonLdProps) {
  const jsonLd = generateBlogPostJsonLd(post, slug);

  return (
    <script
      type="application/ld+json"
      // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD構造化データの出力に必要
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(jsonLd),
      }}
    />
  );
}

データの取り方にもよるけれど、ページレベルのコンポーネントで利用するのが良さそうに思う。

まとめ

JSON-LDについて、いくつかコンポーネント化して出力できるようになった。

元々利用はしていたものの、いざコンポーネント化してみるとまとめられるところがあったりどこに格納するのがいいかなど、考えることがいくつかあることが確認できた。

また、1つのscriptタグに入れる必要は特になさそうなので、それぞれ出力したい箇所に寄せて利用していくのが良さそうに思う。

他のJSON-LDも同じような形で作っていければと思う