swan's room
Blog

Next.js App Router + MDX ブログの設計判断 — 詰まった4つのポイント

このブログ自体を Next.js 16 の App Router と MDX で構築しました。「App Router + MDX」の記事は多いですが、手順を追うだけで設計判断の理由が書かれていないものが多い印象です。なぜその選択をしたのか、どこで詰まったのかに絞って整理しています。

公式ドキュメントを読んで一通り理解した後の「で、実際どう組み合わせるの?」に答えることを目指しています。

@next/mdxnext-mdx-remote-client — 2つ入れた理由

2つの MDX レンダラーが共存する理由を示す構成図

package.json@next/mdxnext-mdx-remote-client が両方入っているのを見て、最初は重複だと思いました。実際には役割が全く異なります。

@next/mdxnext.config.tspageExtensions.mdx を追加するためのものです。about.mdxsrc/app/about/ に置けばそのまま /about として動く、という使い方です。ファイルが Webpack のビルドパイプラインを通るので、import も使えます。

// next.config.ts
const nextConfig: NextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
const withMDX = createMDX({ options: { remarkPlugins: [], rehypePlugins: [] } });
export default withMDX(nextConfig);

一方、ブログ記事は content/blog/*.mdx として管理していて、ファイルシステムから動的に読み込む必要があります。@next/mdx はビルド時にバンドルするので、content/ 配下のファイルを動的に処理するには適していません。next-mdx-remote-clientMDXRemote(RSC 版)を使うことで、Server Component 内でランタイムに MDX を変換できます。

// src/app/blog/[slug]/page.tsx
import { MDXRemote } from "next-mdx-remote-client/rsc";

// post.content は gray-matter で抽出した Markdown 文字列
<MDXRemote source={post.content} components={mdxComponents} />

@next/mdx は「静的ページ」、next-mdx-remote-client は「動的コンテンツ」と使い分けています。

gray-matter の型安全化 — dataany を返す

gray-matter の型安全化フロー図

gray-mattermatter(fileContent).data の型は { [key: string]: any } です。フロントマターのキーを直接参照しても TypeScript はエラーを出しません。

const { data } = matter(fileContent);
data.title;  // any — タイポしても気づけない
data.ttle;   // any — これもエラーにならない

parsePost という薄いラッパー関数を作って、anyPost 型に変換するタイミングを1箇所に集約しています。

// src/lib/mdx.ts
export type Post = {
  slug: string;
  title: string;
  description: string;
  date: string;
  tags: string[];
  published: boolean;
  content: string;
};

const parsePost = (slug: string, fileContent: string): Post => {
  const { data, content } = matter(fileContent);
  return {
    slug,
    title: data.title,
    description: data.description,
    date: data.date,
    tags: data.tags ?? [],       // フィールドが欠けていたら空配列
    published: data.published ?? true,
    content,
  };
};

data.tags ?? [] のフォールバックは地味に重要で、フロントマターに tags を書き忘れた記事があっても undefined が伝播しません。

parsePost を通した後は Post 型として扱えるので、呼び出し側で any が漏れ出さなくなります。getAllPostsgetPostBySlug もこの関数を通すだけです。

contentdata を分離する意味

matter(fileContent){ data, content } を返します。data がフロントマター(YAML)、content が本文 Markdown です。MDXRemote に渡すのは content だけで、data は Next.js のメタデータ生成や記事一覧の表示に使います。この分離を意識しておくと構成が整理しやすいです。

MDX コンポーネントの差し替え — pre"use client" で上書きするときの罠

MDX の Server/Client コンポーネント境界を示す図

コードブロックにコピーボタンを付けたくて、pre 要素をカスタムコンポーネントで上書きしています。

// src/components/mdx/MDXComponents.tsx
export const mdxComponents: MDXComponents = {
  pre: (props) => <CodeBlock {...props} />,
  img: ({ src, alt, ...props }) => src ? <Image src={src} alt={alt ?? ""} ... /> : null,
  // ...
};

CodeBlock の中でコピー機能を実装するには useState が必要で、useState は Client Component でしか使えません。

// src/components/mdx/CodeBlock.tsx
"use client";

export const CodeBlock = ({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) => {
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    const code =
      typeof children === "object" && children !== null && "props" in children
        ? (children as React.ReactElement<{ children?: string }>).props.children ?? ""
        : String(children);

    navigator.clipboard.writeText(code);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className="group relative">
      <pre {...props}>{children}</pre>
      <button onClick={handleCopy}>{copied ? "Copied!" : "Copy"}</button>
    </div>
  );
};

ここで詰まったのが children の型です。MDX がレンダリングする prechildren<code> 要素なので、string ではありません。children.props.children として取り出す必要があります。型ガードとして typeof children === "object" && "props" in children を使っていますが、正直これはやや力技です。もっとスマートな方法があれば教えていただきたいです。

mdx-components.tsx(ルート)と mdxComponents(渡す側)の違い

App Router では @next/mdx のために mdx-components.tsx をプロジェクトルートに置くことが必須です。ただしこれは @next/mdx で処理される .mdx ページ用のものです。next-mdx-remote-clientMDXRemote には components prop で直接渡します。2つが別管理になっている点に注意が必要です。

Next.js 15+ の params async 対応 — 型エラーが出ない破壊的変更

Next.js 15 での params 型変更を示す比較図

Next.js 15 から paramssearchParamsPromise になりました。

// ❌ Next.js 14 まで
export default async function Page({ params }: { params: { slug: string } }) {
  const { slug } = params; // そのまま使えていた
}

// ✅ Next.js 15+
export default async function Page(props: { params: Promise<{ slug: string }> }) {
  const { slug } = await props.params; // await が必要
}

この変更で厄介なのが、型エラーが出ないケースがある点です。params を分割代入で受け取っていると、型の不整合が静的に検出されないことがあります。実際、generateMetadata の方は気づいていたのに、ページコンポーネント本体の方は見落としていて、ビルドは通るのに実行時に動かないという状況になりました。

修正箇所は generateMetadata と ページコンポーネントの2箇所です。どちらも props としてまとめて受け取って await props.params するパターンが安全です。

export async function generateMetadata(
  props: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await props.params;
  // ...
}

export default async function BlogPostPage(
  props: { params: Promise<{ slug: string }> }
) {
  const { slug } = await props.params;
  // ...
}

OG 画像の Edge Runtime — フォント fetch は毎回走る

OG 画像生成における Edge Runtime のフォント fetch フロー

/api/ogruntime = "edge" で実装しています。ImageResponse で OG 画像を動的生成する際、日本語タイトルを表示するために日本語フォントが必要です。

// src/app/api/og/route.tsx
export const runtime = "edge";

export const GET = async (request: NextRequest) => {
  const notoSansJPData = await fetch(
    new URL("https://fonts.gstatic.com/s/notosansjp/v53/...ttf")
  ).then((res) => res.arrayBuffer());
  // ...
};

これだと OG 画像がリクエストされるたびに fonts.gstatic.com へ外部 fetch が走ります。Edge Runtime でも Next.js の fetch は標準でキャッシュされますが、デフォルトの挙動が変わることがあるので cache: 'force-cache' を明示しておく方が安全です。

const notoSansJPData = await fetch(url, { cache: "force-cache" }).then((res) =>
  res.arrayBuffer()
);

Edge Runtime の制約まとめ

実装中に引っかかった制約を整理しておきます。

  • Node.js API が使えないfspath 等は使えません。Web 標準 API のみです
  • ImageResponse の CSS は flex のサブセットgridposition: absolute は非対応です。全て display: flex で組む必要があります
  • -webkit-box が必要 — 行数制限には WebkitLineClampWebkitBoxOrient を使います。通常の CSS の line-clamp は効きません
  • 日本語フォントは必須 — フォントを指定しないと日本語が豆腐(□)になります

「なるほど」ポイント4つ

  1. @next/mdxnext-mdx-remote-client は役割が違う — 前者はビルド時バンドル(静的ページ向き)、後者は RSC でのランタイム変換(動的コンテンツ向き)。両方入れるのは重複ではありません
  2. gray-matter の dataanyparsePost のような変換関数を1箇所に集めて、any が外に漏れないようにするのが基本です。?? [] のフォールバックも忘れずに
  3. Next.js 15 の params 変更は型エラーが出ない場合があるprops.paramsawait するパターンに統一しておくと見落としがなくなります。generateMetadata とページコンポーネントの2箇所を忘れずに
  4. Edge Runtime の fetch はキャッシュを明示するcache: 'force-cache' を指定しておくと、フォントの外部 fetch が初回のみになります
Share
Share

S
Swan

ソフトウェアエンジニア。フロントエンドを中心に日々の学びを記録しています。