「CMSでいいじゃん」
わかってる。わかってるんです。でも、MDXで作りたくなる瞬間ってありませんか?
playparkのコーポレートサイトでは、結局Next.js 16 + React 19 + MDXでブログシステムを自作しました。車輪の再発明?いいえ、オーダーメイドの車輪です。(強がり)
この記事で学べること
- MDXファイルベースのコンテンツ管理
- ISRで「ビルドしなくても更新される」を実現
- JST対応の予約投稿(日本で使うなら必須)
- 関連記事のスコアリング(意外と奥が深い)
前提条件
| 項目 | バージョン | 備考 |
|---|---|---|
| Node.js | 20.x 以上 | LTS推奨 |
| Next.js | 16.x | App Router |
| React | 19.x | Server Components対応 |
| TypeScript | 5.x | 型安全は正義 |
依存パッケージ:
npm install gray-matter next-mdx-remote
- gray-matter: frontmatter解析(定番)
- next-mdx-remote: MDXをサーバーでコンパイル(クライアントに重いパーサー送らなくて済む)
実装方法
1. ディレクトリ構成
app/
├── blog/
│ ├── [slug]/page.tsx # 記事詳細
│ ├── page.tsx # 一覧
│ ├── page/[page]/page.tsx # ページネーション
│ ├── category/[category]/ # カテゴリ別
│ └── tag/[tag]/ # タグ別
content/
└── blog/
└── YYYY-MM-DD-slug.mdx # 記事ファイル
lib/
├── blog.ts # データ取得
├── blog-categories.ts # カテゴリ定義
└── mdx.ts # MDX処理
まあ、普通の構成です。奇をてらわない。
2. ファイル名ルール
content/blog/
├── 2026-01-13-first-article.mdx
├── 2026-01-15-second-article.mdx
└── 2026-02-27-this-article.mdx
frontmatter:
---
title: '記事タイトル'
date: 2026-04-06
category: tech-tips
tags:
- Next.js
description: '説明文'
image: '/blog/2026-02-27-slug.webp'
---
slugはファイル名から自動生成。手動で書くの面倒だし、ミスるし。
// lib/blog.ts
const slug = file.replace(/\.mdx$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '');
// "2026-02-27-nextjs-mdx-blog-system.mdx" → "nextjs-mdx-blog-system"
3. ISRで自動更新
// app/blog/page.tsx
export const revalidate = 3600; // 1時間ごと
export default async function BlogPage() {
const { posts } = await getPaginatedBlogPosts(1);
return <BlogList posts={posts} />;
}
なぜ1時間? 予約投稿のため。未来日の記事が公開日時を過ぎたとき、最大1時間で自動表示。
「毎秒revalidateすればいいじゃん」→ はい、Vercelの請求書が怖いです。
4. JST対応の予約投稿
日本のサービスなので日本時間で判定。これ、意外と忘れがち。
// lib/blog.ts
function getTodayInJST(): Date {
const now = new Date();
const jstOffset = 9 * 60 * 60 * 1000; // UTC+9
const jstTime = new Date(now.getTime() + jstOffset);
jstTime.setUTCHours(0, 0, 0, 0);
return jstTime;
}
function parseBlogPost(file: string, fileContent: string): BlogListItem | null {
const { data, content } = matter(fileContent);
// 本番環境では未来日の記事をスキップ
if (data.date && process.env.NODE_ENV === 'production') {
const postDate = parseDateAsJST(data.date);
const today = getTodayInJST();
if (postDate > today) {
return null; // まだ早い
}
}
// ...
}
date: 2026-03-01と書けば、3月1日0時(JST)に自動公開。深夜0時に手動公開する必要なし。(そもそも寝てる)
5. 関連記事スコアリング
単純な「同じカテゴリ」だと微妙。スコアリングで関連度を計算。
// lib/blog.ts
export async function getRelatedPosts(
currentSlug: string,
category: BlogCategory,
tags: string[],
limit: number = 3
): Promise<BlogListItem[]> {
const allPosts = await getAllBlogPosts();
const scoredPosts = allPosts
.filter((post) => post.slug !== currentSlug)
.map((post) => {
let score = 0;
// 同じカテゴリ: +3点
if (post.category === category) {
score += 3;
}
// タグ一致: 各+2点
tags.forEach((tag) => {
if (post.tags.includes(tag)) {
score += 2;
}
});
return { post, score };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score);
return scoredPosts.slice(0, limit).map(({ post }) => post);
}
設計: カテゴリ一致(大分類)を重視しつつ、タグ複数マッチでボーナス。
「Next.js」の記事に「Rust」の記事が関連で出てきたら困るでしょ。(出てきたことある)
6. 読了時間(日本語対応)
英語の「200 words/min」をそのまま使うと、日本語記事で読了時間がバグる。
// lib/blog.ts
function calculateReadingTime(content: string): number {
const charCount = content.length;
const readingTime = Math.ceil(charCount / 400); // 日本語: 約400字/分
return Math.max(1, readingTime);
}
400文字/分で計算。英語とは単位が違う。これ、海外のライブラリそのまま使うと事故る。
動作確認
開発環境で確認
npm run dev
http://localhost:3000/blogでブログ一覧が表示される- 記事をクリックして詳細ページが表示される
- 関連記事が正しく表示される
予約投稿のテスト
// 開発環境では未来日も表示される(NODE_ENV !== 'production')
// 本番環境でのみ非表示になることを確認
// 方法1: 本番ビルドして確認
npm run build && npm run start
// 方法2: 環境変数で確認
NODE_ENV=production npm run dev
ISRの確認(Vercel)
- 記事を更新してデプロイ
- 1時間以内にページが更新されることを確認
x-vercel-cache: STALE→HITの遷移を確認
注意点・Tips
タイムゾーンの罠
- サーバー時刻に依存しない: Vercelのサーバーは UTC。JST変換を忘れると9時間ズレる
- Date オブジェクトの罠:
new Date('2026-03-01')はローカルタイムゾーンで解釈される → 明示的にJST変換
ISRの落とし穴
- revalidate短すぎ: コスト増加(Vercel課金)
- revalidate長すぎ: 記事更新が反映されない
- 1時間がバランス良い(個人の感想)
MDXコンポーネント埋め込み
MDX最大の強み。技術記事で「実際に動くデモ」を埋め込める。
# 記事タイトル
普通のマークダウンです。
<InteractiveDemo />
↑ Reactコンポーネントが動く
ただし、ビルド時に静的化されるのでクライアントにMDXパーサーを送らなくていい。軽い。これ大事。
なぜCMSじゃなくMDXなのか
| 理由 | 詳細 |
|---|---|
| Git管理 | git diff で記事の変更履歴が見える。PRで「この表現どうですか」ができる |
| コンポーネント埋め込み | インタラクティブなデモを記事内に配置可能 |
| 静的化 | ビルド時にHTMLになる。パーサー不要で軽量 |
| CMSの管理画面を開くのが面倒 | 本音 |
まとめ
| やったこと | 感想 |
|---|---|
| MDXベース | Gitで管理できて最高 |
| ISR | ビルドしなくて済むの神 |
| 予約投稿 | 深夜に起きなくていい |
| 関連記事 | スコアリング、地味に楽しい |
「CMSでいいじゃん」は正論。でも、自分たちのワークフローに最適化されたシステムには独自の価値があります。
...と言いつつ、記事が増えてきたら「やっぱCMS入れるか」ってなるかも。(フラグ)
あわせて読みたい
- ブログ運用を完全自動化 — GitHubリポジトリから記事・サムネイル・SNS投稿まで — このブログシステムの運用自動化事例
→ 気軽に相談する



