ミニマリズムを追求した Next.js 15 + MDX ブログテンプレート "next-minimal-blog" を作ってみた
この記事の要約 (リポジトリだけ見たい人向け)
Next.js (15.2.1) + MDX + shadcn/ui + Tailwind CSS で「公式ライク && ミニマルなブログテンプレート」を作成しました!
以下のリポジトリで公開しています!ぜひ見ていってください!
GitHub - cakegaly/next-minimal-blog: A lightweight, minimalistic blog template built with Next.js 15, MDX, Tailwind CSS, and shadcn/ui
A lightweight, minimalistic blog template built with Next.js 15, MDX, Tailwind CSS, and shadcn/ui - cakegaly/next-minimal-blog
なお、本ブログもこのテンプレートと同じ構成で構築しています。
はじめに
Next.js は、公式でも紹介されているように、SSR / SG (SSG) / ISR / API Routes / Middleware / RSC など、 「Web アプリケーションに関することなら俺に任せろ!なんでもできるぜ!」フレームワークへと進化しています。
一方で、こんな声もよく耳にするようになりました。
- 「できることが多すぎて、どれを選べばいいかわからない!」
- 「ライブラリ、書き方のパターンが多すぎて迷う!」
- 「進化が速すぎて追いきれない!気づけば古いバージョンのドキュメントを見てたw」
- 「シンプルに始めたいのに、最初から複雑すぎる」
僕自身も、「Next.js を使ってサクッとシンプルなサイトを作りたいだけなのに、気づけば依存や設定ファイルだらけ・・」ということがよくありました。
そこで、「ミニマリストアプローチ!公式の Next.js / Vercel 製だけでどこまでできる!?」をテーマに、 Next.js 15 (App Router) + MDX + shadcn/ui + Tailwind CSS ブログテンプレートを作ってみました。
next-minimal-blog とは?
「ミニマリストアプローチ!公式の Next.js / Vercel 製だけでどこまでできる!?」
をテーマに作成したブログテンプレートです。
技術構成
- Next.js 15.2.1 (App Router) / React 19.0.0
- MDX
- Tailwind CSS + shadcn/ui
おすすめしたい人
- Next.js / Vercel を学びたい初心者
- ヘッドレス CMS, API 連携などのコンテンツ管理を検討中だけど、とりあえずフロントエンドを組んでみたい人
- シンプルで軽量なサイトを持ちたい人
機能一覧
- MDX による記事コンテンツ管理
- RSS XML (Atom) の自動生成
- リンクカード (OGP fetch)
- サイトマップ / Robots.txt 自動生成
- shadcn/ui + Tailwind CSS + rehype-pretty-code によるスタイリング
- ダークモード切り替え
- ページネーション
- lucide-react ベースのアイコン管理
Zenn ライクに「記事のテーマが一目でわかる」デザインを意識して、 記事ごとに icon を指定 すると、ブログカード (記事一覧) のアイキャッチとして表示されるようにしました。
---
title: Next.js についての記事
date: 2025-03-09
icon: nextjs
---
僕が Next.js で開発するときの ESLint, Prettier, VSCode の設定もリポジトリに追加しておきました。
ディレクトリ構成
src/
├── actions/ # Server Actions (例: OGP メタデータ fetch)
├── app/ # App Router ページ群 + RSS, Sitemap, Robots
├── components/ # UI コンポーネント
├── content/ # 記事コンテンツ (MDX)
├── config/ # サイト設定 / タグ管理
├── lib/ # 各種ユーティリティ (MDX, OGP, pagination)
├── styles/ # サイト全体 & MDX スタイル
└── types/ # 型定義
散らかりがちな components/
以下は、用途別に分類してディレクトリを分けています。
shadcn/ui
コンポーネントを管理するディレクトリについては、よりわかりやすくするために、デフォルトの components/ui/
から components/shadcn-ui
にリネームしています。
components/
├── content/ # MDX コンテンツの表示に関するもの
├── icons/ # アイコン素材 (svg)
├── layout/ # レイアウト関連 (site-header, site-footer, mode-switch など)
├── shared/ # 汎用 UI コンポーネント (callout, pagination など)
└── shadcn-ui/ # shadcn/ui コンポーネント
利用しているライブラリ一覧
dependencies:
@mdx-js/mdx, @radix-ui/react-slot, separator, switch, lucide-react, next, react, react-dom
tailwindcss, tailwind-scrollbar, tailwindcss-animate, rehype-pretty-code, remark-gfm
gray-matter, class-variance-authority, clsx, tailwind-merge, next-themes
devDependencies:
@next/eslint-plugin-next, @types, prettier, prettier-plugin-tailwindcss
eslint, eslint-plugin-import, eslint-plugin-react, typescript-eslint, postcss, autoprefixer
ESLint, Prettier, Tailwind に関連するもの以外は、「公式 > 公式推奨 > 必要最低限の外部ライブラリ」の順に厳選しました。
コードブロックのシンタックスハイライト用途には、公式推奨の rehype-pretty-code (shiki) + remark-gfm を利用しています。
@radix-ui
関連は、単体で追加せず、呼び出し元の shadcn/ui コンポーネントで最低限必要なものだけに絞っています。
主な自作機能
「とりあえず 動かせるやつ 欲しいけど 余分なものは なるべく省く (一句)」を実現するために、 以下の機能は、Next.js 標準の API / RSC / route handler だけで完結させました!
機能 | 説明 | 使っているもの |
---|---|---|
MDXコンテンツ管理 | シンプルな記事ファイル (MDX) からデータ取得 | fs , @mdx-js/mdx によるヘルパー関数を自作 |
OGP メタデータ fetch | リンクカード (リンクプレビュー) のための OG タグ取得 | fetch (公式 API だけ) |
RSSフィード出力 | 記事一覧から RSS XML を自動生成 | App Router + Response API |
ページネーション | ページ分割表示 | 自作 paginate 関数 (hooksなし) |
MDXコンテンツ管理
MDX コンテンツは content/
にまとめて管理しています。
MDX ファイルの basename を slug に、記事ページ app/blog/[slug]/page.tsx
のパスとします。
ファイルシステムからの読み込み、gray-matter
による Frontmatter
パース処理などを、ヘルパー関数としてまとめて実現しました。
compileMDX
なしで、RSC 向けにも柔軟に対応できるようにしました。
import fs from 'fs';
import matter from 'gray-matter';
export async function getAllBlogPosts() {
const files = await fs.promises.readdir('src/content/blog');
return await Promise.all(
files.map(async (file) => {
const raw = await fs.promises.readFile(
`src/content/blog/${file}`,
'utf-8'
);
const { data, content } = matter(raw);
return {
metadata: data,
slug: file.replace(/\.mdx$/, ''),
rawContent: content,
};
})
);
}
ヘルパー関数といっても中身はとてもシンプルで、コンテンツを管理しているディレクトリ (今回の場合、src/content/blog/) の対象ファイルを fs で読み込んでいるだけです。
export async function getBlogPostBySlug(slug: string) {
return getBlogPost((post) => post.slug === slug);
}
async function getBlogPost(
predicate: (post: BlogPost) => boolean
): Promise<BlogPost | undefined> {
const posts = await getAllBlogPosts();
return posts.find(predicate);
}
async function getMDXData<T>(dir: string): Promise<MDXData<T>[]> {
const files = await getMDXFiles(dir);
return Promise.all(files.map((file) => readMDXFile<T>(path.join(dir, file))));
}
async function getMDXFiles(dir: string): Promise<string[]> {
return (await fs.promises.readdir(dir)).filter(
(file) => path.extname(file) === '.mdx'
);
}
async function readMDXFile<T>(filePath: string): Promise<MDXData<T>> {
const rawContent = await fs.promises.readFile(filePath, 'utf-8');
const { data, content } = matter(rawContent);
return {
metadata: data as Frontmatter<T>,
slug: path.basename(filePath, path.extname(filePath)),
rawContent: content,
};
}
MDX ファイルの操作をシンプルに整理する考え方は、Vercel の Leerob さんの見解がとても参考になりました。
2023 Blog Refresh
Including some of my latest hot takes (okay they are pretty mild).
また、MDX コンテンツの表示も カスタムコンポーネント (mdx-components.tsx) で柔軟に対応できるようにしました。
スタイル定義は、Tailwind Typography (prose
) を導入せず、コンポーネント側ですべて対応しています。
シンタックスハイライトは、@mdx-js/mdx
で evaluate()
するときのオプションに rehype-pretty-code, remark-gfm を指定して反映させています。
import { evaluate, type EvaluateOptions } from '@mdx-js/mdx';
import * as React from 'react';
import * as runtime from 'react/jsx-runtime';
import rehypePrettyCode from 'rehype-pretty-code';
import remarkGfm from 'remark-gfm';
import { components } from '@/components/content/mdx-components';
interface CustomMDXProps {
source: string;
additionalComponents?: Record<string, React.ComponentType<any>>;
}
const rehypePrettyCodeOptions = {
theme: 'github-dark',
keepBackground: true,
defaultLang: 'plaintext',
};
export async function CustomMDX({
source,
additionalComponents,
}: CustomMDXProps) {
try {
const options: EvaluateOptions = {
...runtime,
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions]],
};
const { default: MDXContent } = await evaluate(source, options);
const mergedComponents = {
...components,
...(additionalComponents || {}),
};
return <MDXContent components={mergedComponents} />;
} catch (error) {
console.error('Error rendering MDX:', error);
return (
<div className="border-destructive/50 bg-destructive/10 text-destructive rounded-md border p-4">
An error occurred while rendering the content.
</div>
);
}
}
export const components = {
h2: (props) => <h2 className="mt-12 text-2xl font-bold" {...props} />,
a: (props) => <a className="text-primary underline" {...props} />,
pre: (props) => <pre className="bg-muted/30 rounded-lg p-4" {...props} />,
Callout,
LinkPreview,
// and ...
};
OGP メタデータの fetch と合わせて、URL を指定すれば、リンクカードのプレビューも表示できます。
例として、MDX コンテンツ内で
GitHub リポジトリは、こちらです。
<LinkPreview url="https://github.com/cakegaly/next-minimal-blog" />
とすると、
GitHub リポジトリは、こちらです。
GitHub - cakegaly/next-minimal-blog: A lightweight, minimalistic blog template built with Next.js 15, MDX, Tailwind CSS, and shadcn/ui
A lightweight, minimalistic blog template built with Next.js 15, MDX, Tailwind CSS, and shadcn/ui - cakegaly/next-minimal-blog
と表示されます。
OGP メタデータ fetch (リンクプレビュー)
リンクカード (リンクプレビュー) 用の OGPメタデータは、Server Action で fetch して取得しています。
'use server';
import { cache } from 'react';
interface OGData {
title: string;
description: string;
image: string;
url: string;
}
export const getOGData = cache(
async (url: string): Promise<Partial<OGData>> => {
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'bot',
},
});
const html = await response.text();
const getMetaContent = (property: string): string | undefined => {
const regex = new RegExp(
`<meta[^>]+(?:property|name)="${property}"[^>]+content="([^"]+)"`,
'i'
);
return regex.exec(html)?.[1];
};
const titleMatch = /<title>(.*?)<\/title>/i.exec(html);
return {
title: getMetaContent('og:title') || titleMatch?.[1] || '',
description:
getMetaContent('og:description') ||
getMetaContent('description') ||
'',
image: getMetaContent('og:image') || '',
url,
};
} catch (error) {
console.error('Error fetching OG data:', error);
return { url };
}
}
);
なお、無駄な fetch を減らすため、内部へのリンク (サイト内のブログ記事参照) であるかどうかを判定するようにしています。
function isInternalBlogLink(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.pathname.startsWith('/blog/');
} catch {
return url.startsWith('/blog/');
}
}
export function LinkPreview({ url, className, hideImage }: LinkPreviewProps) {
const isInternal = !url.startsWith('http') && isInternalBlogLink(url);
return (
<Suspense
fallback={
<LinkCardSkeleton className={className} hideImage={hideImage} />
}
>
{isInternal ? (
// ...
) : (
// ...
)}
</Suspense>
);
}
RSS / Sitemap 自動生成
next-sitemap
などのパッケージは利用せずに、Route Handlers だけで完結させています。
RSS (Atom で出力する例):
import { NextResponse } from 'next/server';
import { siteConfig } from '@/config/site';
import { getAllBlogPosts } from '@/lib/mdx';
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || siteConfig.url;
const posts = await getAllBlogPosts();
const rssXml = `
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Your Blog Name</title>
<link>${baseUrl}</link>
<description>Your blog description here</description>
<language>ja</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${baseUrl}/rss.xml" rel="self" type="application/rss+xml"/>
${posts
.map((post) => {
return `
<item>
<title><![CDATA[${post.metadata.title}]]></title>
<link>${baseUrl}/blog/${post.slug}</link>
<guid isPermaLink="true">${baseUrl}/blog/${post.slug}</guid>
<pubDate>${new Date(post.metadata.date).toUTCString()}</pubDate>
<description><![CDATA[${post.metadata.description || ''}]]></description>
${
post.metadata.tags
? post.metadata.tags
.map((tag) => `<category>${tag}</category>`)
.join('')
: ''
}
</item>
`;
})
.join('')}
</channel>
</rss>
`.trim();
return new NextResponse(rssXml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
});
}
サイトマップ (トップページと記事ページを出力する例):
import { siteConfig } from '@/config/site';
import { getAllBlogPosts } from '@/lib/mdx';
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || siteConfig.url;
const posts = await getAllBlogPosts();
const blogEntries = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.metadata.date),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
const staticPages = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1.0,
},
];
return [...staticPages, ...blogEntries];
}
ページネーション生成
ページ数の合計をもとに、前後のページリンクを取得するだけのシンプルな処理を追加しました。
export interface PaginationResult<T> {
items: T[];
currentPage: number;
totalPages: number;
totalItems: number;
}
export function paginateItems<T>(
items: T[],
page = 1,
pageSize = 10
): PaginationResult<T> {
const totalItems = items.length;
const totalPages = Math.ceil(totalItems / pageSize);
const currentPage = Math.max(1, Math.min(page, totalPages));
const startIndex = (currentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, totalItems);
return {
items: items.slice(startIndex, endIndex),
currentPage,
totalPages,
totalItems,
};
}
shadcn/ui の Pagination コンポーネントを導入しても (より柔軟なページネーションを) 実現できます。ブログ用途であればそこまでページ数もかさまないし、hooks なしの関数ベースにしたほうが楽に管理できそうと判断して、今回は見送りました。
microCMS, Notion API など、外部のヘッドレス CMS と連携する場合は、MDX ヘルパー関数 lib/mdx.ts を、CMS からデータフェッチするヘルパー関数 (例: lib/microcms.ts) に置き換えれば、対応可能です!
おわりに
公式機能だけで、どこまで「ちょうどいい」ブログが作れるか!?
そんな思い立ちから生まれたテンプレートの紹介でした。
シンプルでミニマルなテンプレートを作ったつもりが、気づけば長い記事になっちゃいましたね(笑) 興味がある方はぜひ Fork や Star してみてください!
GitHub - cakegaly/next-minimal-blog: A lightweight, minimalistic blog template built with Next.js 15, MDX, Tailwind CSS, and shadcn/ui
A lightweight, minimalistic blog template built with Next.js 15, MDX, Tailwind CSS, and shadcn/ui - cakegaly/next-minimal-blog