FumadocsにリンクカードのカスタムMDXコンポーネントを追加する
Fumadocsにはデフォルトでリンクカードが存在しないため、OGP取得付きのLinkCardコンポーネントを自作してMDXに登録する方法を解説します。
はじめに
このブログは Fumadocs を使って構築しています。
FumadocsはAccordionやImageZoom、Tabsなど多くのUIコンポーネントが提供されていますが、リンクカード(LinkCard)コンポーネントはデフォルトでは提供されていません。
Zennやはてなブログなどでおなじみの、URLを貼るだけでOGP情報を取得してカード形式で表示してくれるコンポーネントが欲しかったので、自作してMDXのカスタムコンポーネントとして登録しました。
この記事ではその作成手順を解説します。
補足: この記事で言う「カスタムコンポーネント」はremark/rehypeプラグインではなく、MDXの
componentsプロパティに渡すReactコンポーネントのことです。
全体の構成
今回の実装は大きく3つのパーツで構成されます。
- OGP取得ユーティリティ(
lib/ogp.ts) — URLからOGP情報をフェッチして返す - LinkCardコンポーネント(
app/components/link-card.tsx) — OGP情報をカード形式で描画する - 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 の完全なコードです。
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:title → twitter: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 の完全なコードです。
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 でのオーバーライド:
titleやdescriptionをPropsで渡せば、OGP取得値を上書きできます。
3. MDXカスタムコンポーネントとして登録する
Fumadocsでは、MDXファイル内で使用できるカスタムコンポーネントを page.tsx の components プロパティで登録します。
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 の部分です:
defaultMdxComponentsをスプレッド — Fumadocsが提供するデフォルトのMDXコンポーネント(見出し、コードブロック等)をそのまま使うLinkCardを追加 — オブジェクトのプロパティ名がそのままMDX内のタグ名になる- これだけで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にはデフォルトでリンクカードコンポーネントが存在しませんが、以下の手順で追加できました。
node-html-parserでOGP情報を取得するユーティリティを作成(lib/ogp.ts)- async Server ComponentとしてLinkCardコンポーネントを実装(
app/components/link-card.tsx) page.tsxのcomponentsプロパティにカスタムコンポーネントを登録
Fumadocsではremark/rehypeプラグインを作らずとも、defaultMdxComponents をスプレッドして独自コンポーネントを追加するだけでMDXを拡張できます。
