Next.js で Markdown ブログを構築する方法

2026.02.05
Share:

ZERONOVA LAB のサイトに Focus Blog を追加したとき、「CMS を使うか、Markdown で管理するか」という選択に直面しました。結論として、Markdown + gray-matter によるシンプルな構成を選びました。

この記事では、Next.js App Router で Markdown ブログを構築する方法を、実際の実装をもとに解説します。

なぜ CMS を使わなかったのか

2026年1月27日の開発日記にはこう書いています:

  • Blog の技術構成
    • Markdown + gray-matter で SSG
    • CMS は使わず、シンプルに保つ

CMS を使わない判断をした理由はいくつかあります。

1. 記事は Git で管理したい

開発者向けのブログなので、記事もコードと同じように Git で管理したい。CMS を使うと記事データが外部サービスに依存し、バックアップや履歴管理が複雑になります。

2. ビルド時に静的生成したい

SEO を考えると、サーバーサイドでの動的生成より、ビルド時の静的生成(SSG)のほうが有利です。Markdown ファイルから SSG するのは、CMS の API を叩くより単純です。

3. 将来の移行を考慮

CMS のロックインを避けたい。Markdown ならどのフレームワークにも移行できます。

プロジェクト構成

実装したブログの構成です:

site/src/
├── content/
│   └── blog/           # Markdown ファイル
│       ├── article-1.md
│       └── article-2.md
├── lib/
│   └── blog.ts         # データ取得ユーティリティ
└── app/
    └── focus/
        └── blog/
            ├── page.tsx           # 一覧ページ
            └── [slug]/
                ├── page.tsx       # 記事ページ
                └── BlogContent.tsx # Markdown レンダラー

Markdown ファイルの構造

各記事は frontmatter(YAML ヘッダー)と本文で構成します:

---
title: "記事タイトル"
description: "SEO 用の説明文(120文字程度)"
date: "2026-01-28"
tags: ["Next.js", "Markdown"]
product: "wakulier"
---

## 本文

ここから本文が始まります。

frontmatter には以下を含めます:

  • title: 記事タイトル
  • description: SEO 用のメタディスクリプション
  • date: 公開日(YYYY-MM-DD 形式)
  • tags: カテゴリ分類用のタグ配列
  • product: 関連プロダクト(オプション)

データ取得ユーティリティの実装

gray-matter を使って Markdown ファイルをパースするユーティリティを作ります:

// lib/blog.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDirectory = path.join(process.cwd(), 'src/content/blog');

export interface Post {
  slug: string;
  title: string;
  description: string;
  date: string;
  tags: string[];
  product?: string;
  content: string;
}

export function getAllPosts(): Omit<Post, 'content'>[] {
  const fileNames = fs.readdirSync(postsDirectory);

  const posts = fileNames
    .filter(fileName => fileName.endsWith('.md'))
    .map(fileName => {
      const slug = fileName.replace(/\.md$/, '');
      const fullPath = path.join(postsDirectory, fileName);
      const fileContents = fs.readFileSync(fullPath, 'utf8');
      const { data } = matter(fileContents);

      return {
        slug,
        title: data.title,
        description: data.description,
        date: data.date,
        tags: data.tags || [],
        product: data.product,
      };
    });

  // 日付の降順でソート
  return posts.sort((a, b) =>
    new Date(b.date).getTime() - new Date(a.date).getTime()
  );
}

export function getPostBySlug(slug: string): Post | null {
  const fullPath = path.join(postsDirectory, `${slug}.md`);

  if (!fs.existsSync(fullPath)) {
    return null;
  }

  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);

  return {
    slug,
    title: data.title,
    description: data.description,
    date: data.date,
    tags: data.tags || [],
    product: data.product,
    content,
  };
}

getAllPosts() は一覧表示用にメタデータだけを返し、getPostBySlug() は本文も含めて返します。これにより、一覧ページでは軽量なデータだけを扱えます。

一覧ページの実装

App Router の Server Component として実装します:

// app/focus/blog/page.tsx
import { getAllPosts } from '@/lib/blog';
import Link from 'next/link';

export default function BlogPage() {
  const posts = getAllPosts();

  return (
    <div>
      <h1>Blog</h1>
      <div className="grid gap-6">
        {posts.map(post => (
          <Link
            key={post.slug}
            href={`/focus/blog/${post.slug}`}
          >
            <article className="p-6 border rounded-lg">
              <h2>{post.title}</h2>
              <p>{post.description}</p>
              <time>{post.date}</time>
              <div className="flex gap-2">
                {post.tags.map(tag => (
                  <span key={tag}>{tag}</span>
                ))}
              </div>
            </article>
          </Link>
        ))}
      </div>
    </div>
  );
}

Server Component なので、getAllPosts() はサーバーサイドで実行されます。ファイルシステムへのアクセスが直接可能です。

記事ページの実装(SSG 対応)

動的ルートを SSG するには generateStaticParams を使います:

// app/focus/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from '@/lib/blog';
import { notFound } from 'next/navigation';
import { BlogContent } from './BlogContent';

// ビルド時に全記事のパスを生成
export async function generateStaticParams() {
  const slugs = getAllPostSlugs();
  return slugs.map(slug => ({ slug }));
}

// 記事ごとのメタデータ
export async function generateMetadata({
  params
}: {
  params: { slug: string }
}) {
  const post = getPostBySlug(params.slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.date,
    },
  };
}

export default function PostPage({
  params
}: {
  params: { slug: string }
}) {
  const post = getPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <time>{post.date}</time>
      </header>
      <BlogContent content={post.content} />
    </article>
  );
}

generateStaticParams で全記事の slug を返すことで、ビルド時に全ページが静的生成されます。

Markdown レンダリング

Markdown の描画には react-markdown を使います。GFM(GitHub Flavored Markdown)対応のため remark-gfm も追加:

// app/focus/blog/[slug]/BlogContent.tsx
'use client';

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

interface BlogContentProps {
  content: string;
}

export function BlogContent({ content }: BlogContentProps) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        // コードブロックのカスタマイズ
        code({ className, children }) {
          const match = /language-(\w+)/.exec(className || '');
          return match ? (
            <pre className={`language-${match[1]}`}>
              <code>{children}</code>
            </pre>
          ) : (
            <code className={className}>{children}</code>
          );
        },
        // 見出しに ID を追加(目次用)
        h2({ children }) {
          const id = String(children)
            .toLowerCase()
            .replace(/\s+/g, '-');
          return <h2 id={id}>{children}</h2>;
        },
      }}
    />
  );
}

'use client' ディレクティブが必要なのは、react-markdown がクライアントコンポーネントだからです。Markdown の内容自体はサーバーで取得し、レンダリングだけをクライアントで行います。

タグページの実装

タグでフィルタリングした記事一覧を表示するページも SSG します:

// lib/blog.ts に追加
export function getAllTags(): string[] {
  const posts = getAllPosts();
  const tagSet = new Set<string>();

  posts.forEach(post => {
    post.tags.forEach(tag => tagSet.add(tag));
  });

  return Array.from(tagSet).sort((a, b) =>
    a.localeCompare(b, 'ja')
  );
}

export function getPostsByTag(tag: string) {
  return getAllPosts().filter(post =>
    post.tags.includes(tag)
  );
}
// app/focus/blog/tags/[tag]/page.tsx
import { getAllTags, getPostsByTag } from '@/lib/blog';

export async function generateStaticParams() {
  const tags = getAllTags();
  return tags.map(tag => ({ tag }));
}

export default function TagPage({
  params
}: {
  params: { tag: string }
}) {
  const posts = getPostsByTag(params.tag);
  // ... 記事一覧を表示
}

sitemap への自動追加

sitemap.ts で全記事と全タグページを含めます:

// app/sitemap.ts
import { getAllPosts, getAllTags } from '@/lib/blog';

export default function sitemap() {
  const baseUrl = 'https://zeronova-lab.com';
  const posts = getAllPosts();
  const tags = getAllTags();

  const blogUrls = posts.map(post => ({
    url: `${baseUrl}/focus/blog/${post.slug}`,
    lastModified: new Date(post.date),
    priority: 0.7,
  }));

  const tagUrls = tags.map(tag => ({
    url: `${baseUrl}/focus/blog/tags/${tag}`,
    lastModified: new Date(),
    priority: 0.6,
  }));

  return [
    { url: baseUrl, priority: 1.0 },
    { url: `${baseUrl}/focus/blog`, priority: 0.8 },
    ...blogUrls,
    ...tagUrls,
  ];
}

学んだこと

開発日記からの学び:

  • Blog 基盤は早めに作っておくと、記事を量産しやすい

Markdown ブログの基盤を最初に作っておくと、記事を追加するのは Markdown ファイルを作るだけになります。CMS にログインする必要も、管理画面を操作する必要もない。開発者にとっては、これが最も効率的なワークフローです。

また、以下のことも学びました:

1. Server Component と Client Component の使い分け

データ取得は Server Component で行い、インタラクティブな描画だけを Client Component に任せる。これにより、ページの初期表示が速くなります。

2. SSG のためには generateStaticParams が必須

App Router で動的ルートを SSG するには generateStaticParams が必要です。これを忘れると、ビルド時ではなくリクエスト時にページが生成されてしまいます。

3. gray-matter はシンプルで強力

frontmatter のパースに gray-matter を使うことで、複雑な設定なしに YAML ヘッダーを扱えます。

まとめ

Next.js App Router で Markdown ブログを構築する手順:

  1. Markdown ファイルを用意: frontmatter + 本文の構造
  2. データ取得ユーティリティ: gray-matter でパース
  3. 一覧ページ: Server Component で全記事を取得
  4. 記事ページ: generateStaticParams で SSG
  5. Markdown 描画: react-markdown + remark-gfm
  6. タグページ: 同様に SSG
  7. sitemap 対応: 全ページを含める

CMS を使わないシンプルな構成ですが、SEO、OGP、sitemap まで対応できます。開発者向けのブログなら、この構成がおすすめです。

関連記事として、Next.js App Router で SEO 最適化する完全ガイド ではメタデータ設定から sitemap、robots.txt まで包括的な SEO 対策を解説しています。また、Next.js ブログに構造化データ(JSON-LD)を実装する方法 では BlogPosting や BreadcrumbList の実装パターンを紹介しています。

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

ZERONOVA LAB

AIネイティブ実験開発スタジオ