ミニマリズムを追求した Next.js 15 + MDX ブログテンプレート "next-minimal-blog" を作ってみた

「ミニマリストアプローチ!公式の Next.js / Vercel 製だけでどこまでできる!?」をテーマに作成した Next.js テンプレート next-minimal-blog を紹介します。

この記事の要約 (リポジトリだけ見たい人向け)

github.com

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 (15.2.1) + MDX + shadcn/ui + Tailwind CSS で「公式ライク & ミニマルなブログテンプレート」を作成しました!

はじめに

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 によるヘルパー関数を自作
RSSフィード出力記事一覧から RSS XML を自動生成App Router + Response API
OGP メタデータ fetchリンクカード (リンクプレビュー) のための OG タグ取得fetch (公式 API だけ)
ページネーションページ分割表示自作 paginate 関数 (hooksなし)

OGP メタデータ fetch (リンクプレビュー)

リンクカード (リンクプレビュー) 用の OGPメタデータは、Server Action で fetch して取得しています。

actions/fetch-og-metadata.ts
'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 };
    }
  }
);

RSS / Sitemap 自動生成

next-sitemap や外部パッケージを使わずに、App Router の route handler だけで完結させています。

RSS (Atom で出力する例):

app/rss.xml/route.ts
import { siteConfig } from '@/config/site';
import { getAllBlogPosts } from '@/lib/mdx';
import { NextResponse } from 'next/server';
 
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',
    },
  });
}

サイトマップ (トップページと記事ページを出力する例):

app/sitemap.ts
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];
}

MDXコンテンツ管理

ファイルシステムからの読み込み、gray-matter による Frontmatter パース処理などを、ヘルパー関数としてまとめて実現しています。 compileMDX なしで、RSC 向けにも柔軟に対応できるようにしました。

lib/mdx.ts
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,
      };
    })
  );
}

MDX ファイルの操作をシンプルに整理する考え方は、Vercel の Leerob さんの見解を参考にしました。

archive.leerob.io

2023 Blog Refresh

Including some of my latest hot takes (okay they are pretty mild).

また、MDX コンテンツの表示も カスタムコンポーネント (mdx-components.tsx) で柔軟に対応できるようにしました。 スタイル定義は、Tailwind Typography (prose) を導入せず、コンポーネント側ですべて対応しました。 シンタックスハイライトは、@mdx-js/mdxevaluate() するときのオプションに rehype-pretty-code, remark-gfm を指定して反映させています。

components/content/custom-mdx.tsx
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>
    );
  }
}
src/components/content/mdx-components.tsx
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 を指定すれば、リンクカードのプレビューも表示できます。

例として、

GitHub リポジトリは、こちらです。
 
<LinkPreview url="https://github.com/cakegaly/next-minimal-blog" />

とすると、

GitHub リポジトリは、こちらです。

github.com

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

と表示されます。

ページネーション生成

ページ数の合計をもとに、前後のページリンクを取得するだけのシンプルな処理で対応しました。

lib/pagination.ts
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.com

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