8分

ブログを移行しました

blognextjsmdxtailwindcssarchitecture

なぜブログを移行したか

以前からブログは運用していた。でも、書く体験に不満があった。自分のドメインで、自分の好きなように、もっとシンプルに書きたかった。MDXのファイルを1つ置けば記事になる。それだけの仕組みが欲しかった。

今回、ブログ基盤をNext.js 16 + MDX + Tailwind CSS v4でゼロから作り直した。「書くことに集中する」ためのインフラとして再設計し、記事を書く体験を最大限シンプルに保ちつつ、読む側にとっては高速で美しいページを提供する。そのバランスを技術でどう実現したかを書く。

ディレクトリ構成

project structure
.
├── app/                        # Next.js App Router
│   └── [locale]/
│       └── blog/
│           └── [slug]/
│               ├── page.tsx            # Blog post page (RSC)
│               └── opengraph-image.tsx # OG image (build-time)
├── content/blog/               # Blog posts (MDX files)
│   └── hello-world/
│       ├── ja.mdx              # Japanese version
│       └── en.mdx              # English version
├── components/blog/            # Blog-specific components
├── lib/
│   ├── blog.ts                 # Post metadata, pagination
│   ├── mdx.ts                  # MDX compilation pipeline
│   └── image-upload.ts         # Image optimization pipeline
└── public/images/blog/         # Local image fallback (dev)

ポイントはcontent/blog/ディレクトリ。各記事はslug名/のディレクトリにja.mdxen.mdxを置くだけ。ファイルシステムがそのままCMSになる。データベースはない。

MDXという選択

MDXはMarkdownにJSXを埋め込める形式だ。frontmatterにメタデータを書き、本文をMarkdownで書く。それだけで記事が完成する。

frontmatter example
title: "ブログを移行しました"
description: "ブログ基盤を作り直した話"
date: "2026-03-16"
tags: ["blog", "nextjs"]
published: true

published: falseにすれば下書き状態。デプロイしても公開されない。シンプルなフラグ1つでコントロールできる。

コードブロックはShikiでビルド時にハイライトされる。ランタイムでJavaScriptは一切動かない。ライトモードとダークモードの両テーマを事前生成する。

lib/mdx.ts
const prettyCodeOptions: PrettyCodeOptions = {
  theme: {
    dark: "github-dark-dimmed",
    light: "github-light",
  },
  keepBackground: false,
};

数式もKaTeXで対応している。

×E=Bt\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}

画像パイプライン: コピペだけで完結する

ブログを書くとき、画像の扱いは最も面倒な作業の一つだ。スクリーンショットを撮る → リサイズする → WebPに変換する → 適切なディレクトリに配置する → Markdownにパスを書く。この手順を全て自動化した。

エディタのテキストエリアに画像をCtrl+Vでペーストするだけ。あとは自動で以下が実行される:

  1. クリップボードから画像ファイルを抽出
  2. プレースホルダー(![uploading...]() )をカーソル位置に挿入
  3. Sharpで最適化(WebP変換、最大幅1920px、品質75%)
  4. Cloudflare R2にアップロード(開発環境はローカル保存)
  5. プレースホルダーを実際のMarkdown画像構文に置換
lib/image-upload.ts
export async function optimizeImage(buffer: Buffer) {
  const image = sharp(buffer);
  const metadata = await image.metadata();
 
  const needsResize = metadata.width !== undefined && metadata.width > 1920;
  const pipeline = needsResize ? image.resize({ width: 1920 }) : image;
 
  return pipeline
    .webp({ quality: 75, effort: 4 })
    .toBuffer({ resolveWithObject: true });
}

セキュリティ面では、MIMEタイプのチェックだけでなくSharpでマジックバイトを検証し、偽装ファイルを弾く。ファイルサイズは3MBに制限。アップロードは並列2件まで。

書き手が意識するのは「Ctrl+V」だけ。それ以外は全てシステムが処理する。

SSG: ゼロランタイムの静的サイト

このブログは完全な静的サイトだ。generateStaticParamsで全記事のページをビルド時に生成する。

app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const slugs = getPostSlugs();
  const params: { locale: string; slug: string }[] = [];
  for (const slug of slugs) {
    for (const locale of ["ja", "en"]) {
      if (fs.existsSync(`content/blog/${slug}/${locale}.mdx`)) {
        params.push({ locale, slug });
      }
    }
  }
  return params;
}

MDXのコンパイル、Shikiによるシンタックスハイライト、KaTeXの数式レンダリング—すべてがビルド時に完了する。ユーザーのブラウザではReact Server Componentsで生成された静的HTMLが表示されるだけ。

ISR(Incremental Static Regeneration)は使っていない。記事を更新したらデプロイする。それでいい。シンプルさが正義だ。

OGP画像の自動生成

SNSでシェアされたときのOG画像も、全記事に対してビルド時に自動生成される。Next.jsのImageResponse(Satoriベース)を使う。

opengraph-image.tsx
export default async function OGImage({ params }) {
  const { locale, slug } = await params;
  const post = getPost(slug, locale as Locale);
 
  return new ImageResponse(
    <div style={{
      background: "linear-gradient(135deg, #0f172a, #1e293b)",
      // title, description, site name, date...
    }}>
      {post.meta.title}
    </div>,
    { width: 1200, height: 630 }
  );
}

記事のタイトル・説明文・日付がフロントマターから読み取られ、1200x630pxのPNG画像として出力される。Figmaでデザインする必要はない。記事を書けばOGPも勝手にできる。

加えて、TechArticle・BreadcrumbList・FAQの構造化データ(JSON-LD)も自動出力される。SEOのためにやることは、フロントマターを丁寧に書くことだけ。

デザインシステム

色やフォントを個別に指定することは禁じている。全てCSS変数とTailwind CSS v4のユーティリティクラスで統一する。

app/globals.css (excerpt)
:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.546 0.245 262.881);
  --muted: oklch(0.97 0 0);
}
 
.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
}

フォントはNoto Sans JP(日本語)とInter(欧文)の組み合わせ。日本語の組版にはword-break: auto-phraseline-break: strictを適用し、自然な改行を実現している。

ダークモード対応はCSSカスタムプロパティの切り替えだけ。コンポーネント側でダークモードを意識するコードは一行も書いていない。

多言語対応(i18n)

next-intlで日本語と英語の2言語に対応した。記事ごとにja.mdxen.mdxを配置するだけで、URLは自動的に/ja/blog/slug/en/blog/slugになる。

サイトマップにはhreflang属性が自動付与され、Google検索に各言語の存在を通知する。canonicalURLも言語ごとに適切に設定される。

まとめ

今回の移行で実現したかったのは「書くことへの摩擦をゼロにする」ことだ。

  • MDXファイルを1つ書けば記事になる
  • 画像はコピペするだけで最適化・配信される
  • OGPもSEOもビルド時に自動生成
  • デザインはテーマ変数で一貫管理
  • 多言語はファイルを追加するだけ

以前のブログで感じていた不満を全て解消できた。技術は手段であって目的ではない。でも、適切な技術選定が「書く」という行為の障壁を限りなくゼロに近づけてくれる。移行して良かったと思えるブログ基盤ができた。