rammaru.com
·技術

Next.js 16 で個人ブログを作り直した

Next.js 16 + next-intl + Velite で多言語対応の個人ブログ兼ポートフォリオを構築した記録。設計判断とハマりどころをまとめる。

最初の記事として、このブログ自体について書きます。何を使ってどう作ったか、どこで詰まったか、これからどう運用するか、を順にまとめます。

なぜ作り直したか

これまで色々なプラットフォームに記事を散らしてきましたが、長く付き合うほどに以下が欠かせないと感じるようになりました。

  • 書いたものは自分の資産として残したい。GitHub の履歴に乗せたい。
  • SEO で正面から戦える土台が欲しい。記事ページが静的 HTML として配信されること、メタデータが完全に制御できること。
  • 日本語と英語を両方扱いたい。日本のローカル文脈と海外スタートアップ文脈、両方に届けたい。
  • トップページで遊びたい。3D 表現を入れる余地は残しておきたい。

これを全部満たそうとすると、結局自分でホスティング先と CMS と表示層を組み合わせる以外に道はありませんでした。

アーキテクチャ全体像

採用したスタックはこれです。

領域選択
フレームワークNext.js 16 (App Router, Turbopack)
多言語next-intl 4
記事ソースMDX + Velite (Zod スキーマ)
スタイルTailwind CSS 4
ホスティングVercel
3D 余地React Three Fiber(components/three/ を予約)

「トップで魅せるページ」と「記事の SEO ページ」を分離する方針は最初から決めていました。トップは後で 3D を載せるとして、記事ページはとにかく堅く静的生成にこだわっています。

設計判断

カテゴリは4個に絞り、ディレクトリでは分けない

カテゴリは tech product business essay の4つに固定しました。5個以上に増やすと「これどこに入れる?」で書く前に手が止まるからです。

物理ディレクトリは locale だけ で分割し、カテゴリは frontmatter のフィールドで管理しています。これで分類を変えても git mv が発生せず、複数カテゴリにまたがる記事を将来扱いたくなっても破綻しません。

content/blog/
  ja/2026-05-24-hello-world.mdx
  en/2026-05-24-hello-world.mdx

記事 URL はフラットに保つ

記事 URL は /[locale]/blog/<slug> だけ。カテゴリを URL に含めません。記事の分類を変えたときに URL が壊れるのを避けるためです。

カテゴリページは /[locale]/blog/category/<category> として別建てし、Google にインデックスさせます。タグページは /[locale]/blog/tag/<tag> ですが、薄いコンテンツの量産で SEO を汚さないよう noindex を付けています。

translationKey で hreflang を機械的に連結

frontmatter に translationKey を持たせ、日本語版と英語版で同じ値にすれば「同じ記事の翻訳同士」と判定できます。これを使って <link rel="alternate" hrefLang> を自動生成しました。

Next.js 16 でハマったところ

Next.js 16 にはドキュメントを真面目に読まないと刺さる変更がいくつかあります。

middleware.tsproxy.ts に改名

旧来の middleware.tsproxy.ts に名前が変わりました。next-intl の createMiddleware はそのまま使えますが、置き場所が src/proxy.ts になります。

// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
};

paramsPromise で受ける

App Router のページ・レイアウトに渡される paramsPromise 型に変わっています。同期アクセスは型エラー。

export default async function Page({ params }: PageProps<'/[locale]/blog/[slug]'>) {
  const { locale, slug } = await params;
  // ...
}

PageProps<'/...'>LayoutProps<'/...'> はグローバルな型ヘルパとして使えるようになっており、自前で interface を切る必要はありません。

Turbopack + JSON import の罠

Velite が生成する .velite/index.js は JSON を with { type: 'json' } 構文で再エクスポートしますが、dev モードの Turbopack でこれを通すと特定の JSON ファイルが壊れて読まれる症状が出ました。回避策として、サーバー側で fs.readFileSync + JSON.parse する薄いヘルパに切り替えました。production ビルドは generateStaticParams 経由で1度だけ呼ばれるためコストは無視できます。

執筆フロー

スクリプトを2本用意して、書く動作を雑にしました。

pnpm new:post "タイトル" --slug url-friendly-slug --locale ja --category tech
# → content/blog/ja/2026-05-24-url-friendly-slug.mdx を draft:true で生成
# → public/images/blog/url-friendly-slug/ も同時に作成

pnpm publish-post url-friendly-slug --locale ja
# → draft: true → false に書き換え

下書きはコミットしても公開されません(draft: true の記事は一覧・サイトマップ・記事ページから完全に除外されます)。手元で安心して書き散らかせます。

画像は public/images/blog/<slug>/ に置き、MDX 内では普通の Markdown 画像記法でリンクします。VS Code の Paste Image 拡張を使えばクリップボードから貼り付け一発です。

これから

このブログでは次の4つを軸に書いていきます。

  • tech — 技術選定、実装解説、リファクタリングの話
  • product — 自分のプロダクト開発、グロース、失敗談
  • business — 起業や事業の考察、市場分析
  • essay — キャリア観、思想、日常の雑記

直近のロードマップとしては、トップページに React Three Fiber でヒーローシーンを差し込む予定です。テキストは HTML として持ち続けるので、3D は装飾レイヤとして上に重ねる形になります。SEO とビジュアルを両立させたい。

ひとまず、これで運用基盤は揃いました。書きます。