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

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

Next.js (15.2.1) + MDX + shadcn/ui + Tailwind CSS で「公式ライク && ミニマルなブログテンプレート」を作成しました!

以下のリポジトリで公開しています!ぜひ見ていってください!

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 は、公式でも紹介されているように、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 向けにも柔軟に対応できるようにしました。

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,
      };
    })
  );
}

ヘルパー関数といっても中身はとてもシンプルで、コンテンツを管理しているディレクトリ (今回の場合、src/content/blog/) の対象ファイルを fs で読み込んでいるだけです。

lib/mdx.ts
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 さんの見解がとても参考になりました。

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 を指定すれば、リンクカードのプレビューも表示できます。

例として、MDX コンテンツ内で

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


と表示されます。

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 };
    }
  }
);

なお、無駄な fetch を減らすため、内部へのリンク (サイト内のブログ記事参照) であるかどうかを判定するようにしています。

components/content/link-preview.tsx
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 で出力する例):

app/rss.xml/route.ts
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',
    },
  });
}

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

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];
}

ページネーション生成

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

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 コンポーネントについて

shadcn/ui の Pagination コンポーネントを導入しても (より柔軟なページネーションを) 実現できます。ブログ用途であればそこまでページ数もかさまないし、hooks なしの関数ベースにしたほうが楽に管理できそうと判断して、今回は見送りました。

拡張例 (ヘッドレス CMS との連携)

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