最近、同じ月のうちにまったく逆向きのことを二つやった——個人ホームページを Next.js から Vite へ引っ越し、Hugo の静的サイトを丸ごと Next.js へ迎え入れた。どちらでも少しハマったので、覚えているうちに書き留めておく。次にまた忘れないように。
引っ越していったほう
home このリポジトリは私の個人ホームページで、スタックの変遷はこんな感じだ:
| commit | 日付 | 出来事 |
|---|---|---|
| —— | —— | Nuxt 4 / Vue 3 / Pinia |
412b258 | 2026-04-21 03:46 | Next.js 15 の静的エクスポートに書き換え。新コードは next-app/ に置き、public は symlink で Nuxt と共有 |
0e83f8c | 2026-04-21 14:12 | Nuxt を下ろし、Next プロジェクトをルートディレクトリへフラット化 |
081e5b4 | 2026-04-21 14:43 | Next 16 + Tailwind 4 + TypeScript 6 へアップグレード |
3075556 | 2026-05-14 07:50 | Vite 7 へ移行 |
タイミングがちょっと笑える:Nuxt を下ろしてから Next 16 に上げるまでわずか 31 分、同じセッションで大仕事を二つ続けてやった;その結果、22 日後にそのうちの一つをひっくり返すことになった。衝動は悪魔だ。
Vite へ移す前に数えてみた:
# 1. src 下所有 tsx
find src -name "*.tsx" | wc -l # 20
# 2. 其中带 use client 的
grep -rl "use client" src --include="*.tsx" | wc -l # 19tsx が 20 個、うち 19 個は冒頭に "use client" が付いている。唯一の Server Component は src/app/layout.tsx で、これは metadata と viewport をエクスポートし、next/font の Inter を設定し、3 つの preconnect を掛け、html/body の外枠と Toaster をレンダリングしている——やっていることは少なくないが、RSC でなければならないものは一つもない。
次に output: 'export' を見てみる。このサイトは Next 15 の頃からずっと静的エクスポートで、移す前の next.config.ts はこんな感じ:
import type { NextConfig } from "next";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const nextConfig: NextConfig = {
output: "export",
reactStrictMode: true,
trailingSlash: false,
images: { unoptimized: true },
turbopack: {
root: dirname(fileURLToPath(import.meta.url)),
},
};
export default nextConfig;これを選ぶと、SSR / ISR / Route Handler / middleware / next/image 最適化はすべてオフになり、残るのはファイルルーティング、Metadata API、next/font の三つだけ。単一ページのホームページにとっては:ルーティングは使わない、meta は index.html に直書きすればいい、フォントをシステムスタックに変えても誰も気づかない。なら、残しておく意味なんてあるだろうか。
引っ越し後の収支
3075556 のコミットの stat は 41 files changed, 902 insertions(+), 1862 deletions(-)、差し引き 960 行の減。
設定の量を比べるともっと分かりやすい。消したのは next.config.ts(15) + src/app/layout.tsx(69) + next-env.d.ts(6) + serwist.config.js(18) の計 108 行;代わりに得たのは index.html(45) + src/main.tsx(23) + vite.config.ts(30) の計 98 行。行数はほぼ同じだが、後者は一目で理解できる。
ほとんどのコンポーネントの diff は冒頭の 2 行を消しただけだ:
--- a/src/components/app-footer.tsx
+++ b/src/components/app-footer.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState } from "react";当時の vite.config.ts は 30 行だったが、その後少しずつ削って 14 行だけになった:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
server: {
port: 3000,
},
build: {
target: "es2022",
sourcemap: false,
},
});一番よく分かるのは Dockerfile の実行段階だ:
FROM node:22-alpine
RUN npm install -g serve
WORKDIR /app
COPY /app/dist ./public
EXPOSE 12445
CMD ["serve", "public", "-l", "12445"]Node すら動かす必要がなく、静的ファイルが一つ置いてあるだけ。実行段階は潔すぎて少し退屈なくらいだ。
ついでに切り落とした因果の連鎖
フォントのこの連鎖は 2 つのコミットにまたがっている。Vite へ移したコミット(3075556)では next/font/google の Inter が使えなくなり、ローカルの @fontsource パッケージに置き換えたところ、中国語フォントで約 5MB の woff2 が一気に増えた;10 分後の ae004b3 でようやく気づいた——そもそも PWA を入れた主な理由がこの 5MB をキャッシュすることだったのなら、いっそシステムフォントスタック(苹方 / 雅黑 / Roboto)に変えてしまえばいい、というわけで @fontsource と PWA をまとめて削除した。
「削除した」と言うのもあまり正確ではない。log を辿ると、PWA は早くも 5321a54 で一度削除されていて、その後また入れ直され、これが二度目の削除だった。だからこれは全然きれいな意思決定ではなく、二往復もやってようやく落ち着いただけのことだ。
しかも PWA の削除はコードを消すだけでは終わらない。古い訪問者のブラウザにはまだ古い Service Worker が入っているので、自分自身を登録解除する墓碑 SW を残しておく必要がある:
// Tombstone service worker.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(names.map((n) => caches.delete(n)));
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: "window" });
for (const client of clients) client.navigate(client.url);
})(),
);
});キャッシュを消し、登録を解除し、さらに開いているページをもう一度ナビゲートさせる。機能を消すのはコードを消すよりずっと面倒だ。
やり残した片付け
それからあまり格好よくない部分。今日に至るまで:
README.mdにはまだ Next.js 15 + Serwist + Tailwind 3 +NEXT_PUBLIC_プレフィックスと書いてあるが、実際は Vite 7 + Tailwind 4 +VITE_——技術的な説明が一文ごとに間違っている.github/workflows/build.yml:53の artifact はpath: out、一方 Vite のビルド成果物はdistにあり、CI がアップロードしていたものはとうに空っぽだったcomponents.jsonの CSS パスはsrc/app/globals.cssを指しているが、ファイルはとっくにsrc/globals.cssへ移動しているapp-footer.tsx:38のsuppressHydrationWarningはデッドコードになった——今や hydration なんて存在しない
一番笑えるのは 0e83f8c で Nuxt を降ろしたあのとき、commit body には「フレームワークを変えるときに併せて直すべき 5 か所」がきちんと列挙されていたことだ。Dockerfile、vercel.json、GitHub Actions の artifact パス、.dockerignore、README…リストは一字一句間違っていなかった。なのに次の移行ではそれが守られず、私自身も一瞥すらしなかった。
もう一つ付け加えると、e6b5d81 のクリーンアップ時に、shadcn の足場が入れた @radix-ui/react-slot と class-variance-authority という 2 つの依存が見つかった。4-21 に導入されて 5-14 に削除されるまで、23 日間で import はゼロ(プロジェクトにはそもそも button.tsx が存在しない)。しかも .vscode には今も Vue.volar がおすすめとして載っていて、2 つのフレームワークの前の残骸だ。依存の追加は 1 秒、削除は 3 週間待ち…
引っ越してきたほう
もう一方は vns で、Galgame のリソースサイトだ。同じリポジトリに完全な三段構成が見て取れる。old ブランチは Hexo 8.1.1 + NexT、dev/beast は Hugo Extended、そして vns 側では .claude/launch.json の dev server が隣の vns-next ディレクトリを指して bun run dev を走らせている——2 つのリポジトリを並べて見比べながら移行しているのだ。
Next.js へ引っ越す理由は 3 つある。
1 つ目は、静的サイトにはサーバー側がないので、API key をフロントエンドの JS に平文で書くしかないこと。当時 main.js には One API の key がそのまま転がっていた。
const API_URL = "https://example.com/v1/chat/completions";
const API_KEY = "sk-xxxx"; // 就这么明文躺着純粋な静的サイトで AI 機能をやるにはこの道しかなく、選択肢がない。同じものを Next.js なら Route Handler に押し込めて、key をサーバー側に残しておけば済む。なお、こうして公開リポジトリに入ってしまった key は忘れずにローテーションしよう。コードを直すだけでは意味がない。
2 つ目は、このサイトの「データベース」が data/ 配下の 6 つの JSON だということ。広告枠、相互リンク、ダウンロード元、漢化組、すべてビルド時に読み込まれる疑似データベースだ。データを 1 件変えるたびにサイト全体を再ビルドし、デプロイ用ブランチにもう一度 push しなければならず、しかもデプロイブランチは 27.9MB / 1832 ファイル…たった 1 行の JSON を変えるために。
3 つ目は、「動的な感じ」を出すためにクライアント側 JS を 2000 行以上積み上げていたこと。main.js の分割前は 2347 行で、swup によるページ切り替え、遅延読み込み、カウントアニメーション、localStorage のトグル、SSE ストリーミングリクエストを詰め込んでいた。後に 8 つの ES module に分割し、メインファイルは 613 行まで減ったが、分割の過程で踏んだ罠はなかなか典型的だった(以下の数件はすべて dev ブランチ上にある):
4842e804e:ES module に分割した後、VALINE_CONFIGといったトップレベル変数が元のファイルに残ってしまった。モジュールのトップレベルスコープはファイルをまたがないので、コメントコンポーネントは一度も初期化されず、それはユーザーからの報告で判明した187ad71b4:1 つのtry/catchがすべての init を包んでおり、途中で 1 つが壊れると以降がすべて実行されない。ユーザーは「コメントも TOC も壊れた」と報告したが、実は TOC はまったく巻き添えを食らっただけだった。各[name, fn]を独立して catch する形に変更d83c2801a:main.jsが render-blocking スクリプトとして DOM より先に走り、getElementByIdは null を返す。さらに swup のページ切り替えでグローバルの click が重複バインドされ、最後はwindow._fireworkBoundのフラグで底支えすることになった
これらはすべてコンポーネントのライフサイクルがただで与えてくれるものを、自分で書くと一つずつ踏むことになるものだ。
もう一つ忘れがちなのが、URL をそのまま保つこと…Hexo の abbrlink 短縮リンク(crc16/dec、permalink は p/:abbrlink/)が Hugo の slug になり、さらに新サイトの work.id へと変わる中で、/p/{id}/ という古いリンクは三代にわたって保たれてきた。末尾のスラッシュすら動かせない——Artalk/Valine の過去のコメントは /p/{id}/ というスラッシュ付きのパスで保存されているので、1 文字欠ければコメントを失うのと同じだ。そのため vns-next 側ではフレームワーク内蔵の末尾スラッシュリダイレクトを切って自分で引き受ける必要があり、このやり取りは約定ファイルについてのあの記事に完全なコメント付きで貼ってある。
最後に中間状態について。bbps のサイトも output: 'export' で、next.config.ts は全文たった 12 行、リアルタイムデータはクライアント側のポーリングでむしろ回避している。
useEffect(() => {
fetchStats()
const id = setInterval(fetchStats, 30000)
return () => clearInterval(id)
}, [fetchStats])30 秒に一度外部 API を叩く、それで十分…静的な殻 + クライアントポーリング、実に楽ちんだ。
あちこち移して一か月ほど試行錯誤してきたけれど、結局ずっと同じことを問い続けていただけだった。このサイトに、どうしてもサーバー側に置かなくてはならないものがあるのか。少し考えれば分かる。別に神秘的な話でも何でもない。じゃあね〜
