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 ブログを構築する手順:
- Markdown ファイルを用意: frontmatter + 本文の構造
- データ取得ユーティリティ: gray-matter でパース
- 一覧ページ: Server Component で全記事を取得
- 記事ページ: generateStaticParams で SSG
- Markdown 描画: react-markdown + remark-gfm
- タグページ: 同様に SSG
- sitemap 対応: 全ページを含める
CMS を使わないシンプルな構成ですが、SEO、OGP、sitemap まで対応できます。開発者向けのブログなら、この構成がおすすめです。
関連記事として、Next.js App Router で SEO 最適化する完全ガイド ではメタデータ設定から sitemap、robots.txt まで包括的な SEO 対策を解説しています。また、Next.js ブログに構造化データ(JSON-LD)を実装する方法 では BlogPosting や BreadcrumbList の実装パターンを紹介しています。
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。