Fract's Blog
Tips

FumadocsにリンクカードのカスタムMDXコンポーネントを追加する

Fumadocsにはデフォルトでリンクカードが存在しないため、OGP取得付きのLinkCardコンポーネントを自作してMDXに登録する方法を解説します。

はじめに

このブログは Fumadocs を使って構築しています。 FumadocsはAccordionImageZoomTabsなど多くのUIコンポーネントが提供されていますが、リンクカード(LinkCard)コンポーネントはデフォルトでは提供されていません。

Zennやはてなブログなどでおなじみの、URLを貼るだけでOGP情報を取得してカード形式で表示してくれるコンポーネントが欲しかったので、自作してMDXのカスタムコンポーネントとして登録しました。

この記事ではその作成手順を解説します。

補足: この記事で言う「カスタムコンポーネント」はremark/rehypeプラグインではなく、MDXの components プロパティに渡すReactコンポーネントのことです。

全体の構成

今回の実装は大きく3つのパーツで構成されます。

  1. OGP取得ユーティリティlib/ogp.ts) — URLからOGP情報をフェッチして返す
  2. LinkCardコンポーネントapp/components/link-card.tsx) — OGP情報をカード形式で描画する
  3. MDXへの登録app/posts/[[...slug]]/page.tsx) — MDX内で <LinkCard /> を使えるようにする

1. OGP取得ユーティリティの作成

まず、指定されたURLのHTMLをフェッチしてOGPメタデータを抽出するユーティリティを作成します。 HTMLパースには軽量な node-html-parser を使います。

npm install node-html-parser

以下が lib/ogp.ts の完全なコードです。

lib/ogp.ts
import { parse, type HTMLElement } from "node-html-parser";
import { cache } from "react";

export type LinkPreviewMetadata = {
  url: string;
  hostname: string;
  title?: string;
  description?: string;
  siteName?: string;
  image?: string;
  icon?: string;
};

const LINK_CARD_REVALIDATE_SECONDS = 60 * 60 * 6; // 6時間キャッシュ
const USER_AGENT =
  "Mozilla/5.0 (compatible; LinkCardBot/1.0; +https://github.com/ino/blog-and-portfolio)";

const iconSelectors = [
  "link[rel='apple-touch-icon']",
  "link[rel='shortcut icon']",
  "link[rel='icon']",
];

const metaCandidates: Record<string, string[]> = {
  title: ["og:title", "twitter:title", "title"],
  description: ["og:description", "description", "twitter:description"],
  siteName: ["og:site_name"],
  image: ["og:image", "twitter:image"],
};

const absoluteUrl = (value: string | undefined, base: URL): string | undefined => {
  if (!value) return undefined;
  try {
    return new URL(value, base).toString();
  } catch {
    return undefined;
  }
};

const normalizeUrl = (rawUrl: string): URL => {
  try {
    return new URL(rawUrl);
  } catch {
    return new URL(`https://${rawUrl}`);
  }
};

const pickMeta = (root: HTMLElement, key: string): string | undefined => {
  const selectors = metaCandidates[key];
  if (!selectors) return undefined;
  for (const candidate of selectors) {
    const element = root.querySelector(`meta[property='${candidate}'], meta[name='${candidate}']`);
    const content = element?.getAttribute("content")?.trim();
    if (content) return content;
  }
  if (key === "title") {
    return root.querySelector("title")?.text.trim();
  }
  return undefined;
};

const pickIcon = (root: HTMLElement, base: URL): string | undefined => {
  for (const selector of iconSelectors) {
    const node = root.querySelector(selector);
    const href = node?.getAttribute("href")?.trim();
    const sized = absoluteUrl(href, base);
    if (sized) return sized;
  }
  return undefined;
};

const collectMetadata = (
  html: string,
  base: URL,
): Omit<LinkPreviewMetadata, "url" | "hostname"> => {
  const root = parse(html);
  const title = pickMeta(root, "title");
  const description = pickMeta(root, "description");
  const siteName = pickMeta(root, "siteName");
  const image = absoluteUrl(pickMeta(root, "image"), base);
  const icon = pickIcon(root, base);
  return { title, description, siteName, image, icon };
};

export const fetchLinkPreview = cache(async (rawUrl: string): Promise<LinkPreviewMetadata> => {
  const baseUrl = normalizeUrl(rawUrl);
  const fallback: LinkPreviewMetadata = {
    url: baseUrl.toString(),
    hostname: baseUrl.hostname,
  };
  try {
    const response = await fetch(baseUrl.toString(), {
      headers: { "user-agent": USER_AGENT },
      next: { revalidate: LINK_CARD_REVALIDATE_SECONDS },
    });
    if (!response.ok) return fallback;
    const html = await response.text();
    const metadata = collectMetadata(html, baseUrl);
    return { ...fallback, ...metadata };
  } catch {
    return fallback;
  }
});

実装のポイント

メタタグのフォールバック探索: og:titletwitter:title<title> の順で探索し、最初に見つかったものを使うようにしています。OGPが未設定のサイトでもタイトルが取得できます。

Reactの cache: 同一レンダリング内で同じURLの fetchLinkPreview が複数回呼ばれても、実際のフェッチは1回だけに抑えられます。

Next.jsの next.revalidate: ISRキャッシュを6時間に設定し、ビルド後もOGP情報が定期的に更新されるようにしています。

エラー時のフォールバック: フェッチに失敗してもホスト名だけは表示されるよう、最低限の情報を返す設計にしています。

2. LinkCardコンポーネントの作成

次にUIコンポーネントを作成します。async Server Componentとして実装し、サーバー側でOGP情報を取得します。

以下が app/components/link-card.tsx の完全なコードです。

app/components/link-card.tsx
import Link from "next/link";
import type { CSSProperties } from "react";
import { fetchLinkPreview } from "@/lib/ogp";

export type LinkCardProps = {
  url: string;
  title?: string;
  description?: string;
  label?: string;
  className?: string;
};

const descriptionClampStyle: CSSProperties = {
  display: "-webkit-box",
  WebkitLineClamp: 2,
  WebkitBoxOrient: "vertical",
  overflow: "hidden",
};

export async function LinkCard({ url, title, description, label, className = "" }: LinkCardProps) {
  const preview = await fetchLinkPreview(url);
  const siteLabel = label ?? preview.siteName ?? preview.hostname;
  const displayTitle = title ?? preview.title ?? preview.hostname;
  const displayDescription = description ?? preview.description;

  return (
    <Link
      href={preview.url}
      prefetch={false}
      target="_blank"
      rel="noreferrer"
      aria-label={`${displayTitle} へのリンク`}
      className={`group flex w-full gap-4 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/70 p-4 no-underline shadow-sm transition hover:-translate-y-0.5 hover:border-zinc-300 hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:border-zinc-800 dark:bg-zinc-900/60 dark:hover:border-zinc-700 ${className}`.trim()}
    >
      <div className="relative aspect-video w-40 flex-shrink-0 overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
        {preview.image ? (
          <img
            src={preview.image}
            alt={displayTitle}
            loading="lazy"
            className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
          />
        ) : preview.icon ? (
          <div className="flex h-full w-full items-center justify-center bg-white/60 p-6 dark:bg-black/40">
            <img
              src={preview.icon}
              alt={`${siteLabel} のアイコン`}
              loading="lazy"
              className="h-16 w-16 rounded-xl object-contain"
            />
          </div>
        ) : (
          <div className="flex h-full w-full items-center justify-center px-3 text-center text-xs font-semibold text-zinc-400">
            {siteLabel}
          </div>
        )}
      </div>
      <div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
        <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
          {preview.icon ? (
            <img
              src={preview.icon}
              alt={`${siteLabel} のアイコン`}
              loading="lazy"
              className="h-4 w-4 rounded"
            />
          ) : null}
          <span className="truncate">{siteLabel}</span>
        </div>
        <span className="truncate text-base font-semibold text-zinc-900 dark:text-zinc-50">
          {displayTitle}
        </span>
        {displayDescription ? (
          <p className="text-sm text-zinc-600 dark:text-zinc-300" style={descriptionClampStyle}>
            {displayDescription}
          </p>
        ) : null}
        <span className="text-xs text-zinc-400 dark:text-zinc-500">{preview.hostname}</span>
      </div>
    </Link>
  );
}

設計のポイント

  • async Server Component: Next.js App Routerの非同期Server Componentなので、awaitで直接OGPを取得できます。クライアントJSのバンドルが不要です。
  • 3段階のサムネイル表示: OGP画像 → ファビコン → サイト名テキストの順でフォールバックします。
  • descriptionClampStyle: -webkit-line-clamp を使って説明文を最大2行に制限しています。Tailwind CSSだけでは line-clamp の対応が不十分な場合があるため、インラインスタイルで指定しています。
  • Props でのオーバーライド: titledescriptionをPropsで渡せば、OGP取得値を上書きできます。

3. MDXカスタムコンポーネントとして登録する

Fumadocsでは、MDXファイル内で使用できるカスタムコンポーネントを page.tsxcomponents プロパティで登録します。

app/posts/[[...slug]]/page.tsx
import { source } from "../../../lib/source";
import { DocsPage, DocsBody, DocsDescription, DocsTitle } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { LinkCard } from "@/app/components/link-card";

export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
  const params = await props.params;
  const page = source.getPage(params.slug);
  if (!page) notFound();

  const MDX = page.data.body;

  return (
    <DocsPage toc={page.data.toc} full={page.data.full}>
      <DocsTitle>{page.data.title}</DocsTitle>
      <DocsDescription>{page.data.description}</DocsDescription>
      <DocsBody>
        <MDX
          components={{
            ...defaultMdxComponents,
            LinkCard,
            img: (props) => <ImageZoom {...(props as any)} />,
          }}
        />
      </DocsBody>
    </DocsPage>
  );
}

重要なのは components の部分です:

  1. defaultMdxComponents をスプレッド — Fumadocsが提供するデフォルトのMDXコンポーネント(見出し、コードブロック等)をそのまま使う
  2. LinkCard を追加 — オブジェクトのプロパティ名がそのままMDX内のタグ名になる
  3. これだけでMDX内で <LinkCard /> がReactコンポーネントとして認識される

他のカスタムコンポーネントを追加したい場合も、同じように components オブジェクトにプロパティを追加するだけです。

4. MDXファイルでの使い方

登録が完了すると、MDXファイル内で以下のように使用できます。

<LinkCard url="https://nextjs.org/blog" />

URLを渡すだけで、自動的にそのサイトのOGP情報を取得してカード形式で表示されます。

タイトルや説明を手動で指定することも可能です:

<LinkCard
  url="https://www.fumadocs.dev/"
  title="Fumadocs"
  description="Build beautiful documentation sites with Next.js"
/>

まとめ

Fumadocsにはデフォルトでリンクカードコンポーネントが存在しませんが、以下の手順で追加できました。

  1. node-html-parser でOGP情報を取得するユーティリティを作成(lib/ogp.ts
  2. async Server ComponentとしてLinkCardコンポーネントを実装(app/components/link-card.tsx
  3. page.tsxcomponents プロパティにカスタムコンポーネントを登録

Fumadocsではremark/rehypeプラグインを作らずとも、defaultMdxComponents をスプレッドして独自コンポーネントを追加するだけでMDXを拡張できます。

Components | Fumadocs
Fumadocs のアイコンFumadocs
Components | Fumadocs

Additional components to improve your docs

www.fumadocs.dev

On this page