In the same month I did two completely opposite things — I moved my personal homepage off Next.js to Vite, and then moved a whole Hugo static site into Next.js… I hit snags on both sides, so let me write them down while I still remember, so I don’t forget again next time~
The one I moved out
home is my personal homepage repo. The stack’s evolution went like this:
| commit | date | event |
|---|---|---|
| —— | —— | Nuxt 4 / Vue 3 / Pinia |
412b258 | 2026-04-21 03:46 | Rewritten as Next.js 15 static export, new code in next-app/, public shares with Nuxt via symlink |
0e83f8c | 2026-04-21 14:12 | Nuxt retired, Next project flattened to the root directory |
081e5b4 | 2026-04-21 14:43 | Upgraded to Next 16 + Tailwind 4 + TypeScript 6 |
3075556 | 2026-05-14 07:50 | Migrated to Vite 7 |
The timing is pretty funny: only 31 minutes between retiring Nuxt and upgrading to Next 16 — two big moves in the same session; and then 22 days later I overturned one of them… impulse is the devil.
Before moving to Vite I did a count:
# 1. src 下所有 tsx
find src -name "*.tsx" | wc -l # 20
# 2. 其中带 use client 的
grep -rl "use client" src --include="*.tsx" | wc -l # 1920 tsx files, 19 of them starting with "use client"… The only Server Component was src/app/layout.tsx — it exported metadata and viewport, wired up Inter via next/font, mounted three preconnect, rendered the html/body shell and the Toaster — it did a lot, but none of it required RSC.
Now look at output: 'export'. This site had been a static export since Next 15; before the move, next.config.ts looked like this:
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;With that choice, SSR / ISR / Route Handler / middleware / next/image optimization were all off, leaving just file routing, the Metadata API, and next/font. For a single-page homepage: routing is useless, meta can just be hardcoded in index.html, and swapping the font for a system stack is something nobody would notice… so why keep it at all?
The ledger after the migration
The stat for 3075556 was 41 files changed, 902 insertions(+), 1862 deletions(-) — a net reduction of 960 lines.
The config comparison is more telling. What got deleted was next.config.ts(15) + src/app/layout.tsx(69) + next-env.d.ts(6) + serwist.config.js(18), 108 lines total; what I got in return was index.html(45) + src/main.tsx(23) + vite.config.ts(30), 98 lines total. About the same line count, but I can understand the latter at a glance.
Most component diffs were just deleting those two lines at the top:
--- a/src/components/app-footer.tsx
+++ b/src/components/app-footer.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState } from "react";At that point vite.config.ts was 30 lines; later I trimmed it down to just 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,
},
});The clearest illustration is the runtime stage of Dockerfile:
FROM node:22-alpine
RUN npm install -g serve
WORKDIR /app
COPY /app/dist ./public
EXPOSE 12445
CMD ["serve", "public", "-l", "12445"]Not even Node has to run — just a static file sitting there, the runtime stage is almost boringly clean…
A causal chain I cut along the way
The font chain spans two commits. In the Vite migration commit (3075556), Inter in next/font/google was no longer usable, so I swapped in a local @fontsource package, which suddenly added about 5MB of woff2 for Chinese fonts; ten minutes later in ae004b3 it hit me — the main reason I’d installed PWA in the first place was to cache those 5MB, so I might as well switch to a system font stack (PingFang / YaHei / Roboto), and deleted @fontsource along with the PWA.
“Deleted” isn’t quite accurate though… digging through the log, I found the PWA had already been deleted once back in 5321a54, then reinstalled later — this was the second deletion. So this wasn’t some clean decision at all; it took two rounds of back-and-forth before it settled down.
And deleting a PWA isn’t just deleting code. Old visitors still have the old Service Worker in their browsers, so I had to leave a tombstone SW to let it unregister itself:
// 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);
})(),
);
});Clear the cache, unregister, then have already-open pages navigate again… removing a feature is way more hassle than removing code.
The unfinished wrap-up
Then comes the not-so-glorious part. As of today:
README.mdstill says Next.js 15 + Serwist + Tailwind 3 +NEXT_PUBLIC_prefix, but it’s actually Vite 7 + Tailwind 4 +VITE_— every technical description is wrong.github/workflows/build.yml:53's artifact ispath: out, while Vite output lives indist, so whatever CI uploads has long been empty- The CSS path in
components.jsonpoints tosrc/app/globals.css, but the file moved tosrc/globals.cssages ago app-footer.tsx:38'ssuppressHydrationWarningis dead code — there’s no hydration at all anymore
The funniest part is that 0e83f8c, the time I took Nuxt offline, the commit body actually listed a complete “5 things you have to change along with the framework swap”: Dockerfile, vercel.json, GitHub Actions artifact path, .dockerignore, README… The checklist wasn’t wrong by a single word — the next migration just didn’t follow it, and I didn’t bother to look either.
One more: while cleaning up for e6b5d81 I found @radix-ui/react-slot and class-variance-authority, two dependencies installed by the shadcn scaffold — installed 4-21, deleted 5-14, zero imports across 23 days (there’s no button.tsx in the project at all); .vscode still recommends Vue.volar, leftovers from two frameworks ago. Adding a dependency takes a second; deleting one takes three weeks…
The one that moved in
On the other side is vns, a Galgame resource site. In the same repo you can see the full three-stage evolution: the old branch is Hexo 8.1.1 + NexT, dev/beast is Hugo Extended, and over on vns, the dev server in .claude/launch.json points straight at the neighboring vns-next directory running bun run dev — two repos migrating side by side for comparison.
There were three reasons to move it into Next.js.
First, a static site has no server, so API keys can only sit in plaintext in the frontend JS. At the time, main.js had this One API key just lying there:
const API_URL = "https://example.com/v1/chat/completions";
const API_KEY = "sk-xxxx"; // 就这么明文躺着That’s the only option for AI features on a pure static site — no choice. The same thing in Next.js can go into a Route Handler and the key stays server-side. Also, remember to rotate any key that has been in a public repo; changing the code doesn’t count…
Second, this site’s “database” is 6 JSON files under data/. Ad slots, friend links, download sources, translation groups — all fake databases read at build time. Changing one row of data meant rebuilding the whole site and re-pushing the deploy branch, and that branch is 27.9MB / 1832 files… all to change one line of JSON.
Third, to create a “dynamic feel” I’d piled up over two thousand lines of client JS. main.js was 2347 lines before splitting, stuffed with swup page transitions, lazy loading, counter animations, localStorage toggles, SSE streaming requests. Later it was split into 8 ES modules and the main file dropped to 613 lines, but the pitfalls along the way were pretty typical (these are all on the dev branch):
4842e804e: after splitting into ES modules, top-level variables likeVALINE_CONFIGstayed in the original file. Module top-level scope doesn’t span files, so the comment component never initialized — and it was users who reported it187ad71b4: a singletry/catchwrapped all the inits, so one crash mid-way killed everything after it. Users reported “comments and TOC are both broken” — the TOC was purely collateral damage. Changed to an independent catch per[name, fn]d83c2801a:main.jsruns before the DOM as a render-blocking script, sogetElementByIdreturns null; swup transitions also caused the global click handler to be bound repeatedly, finally bailed out with awindow._fireworkBoundflag
All of these are things a component lifecycle gives you for free; hand-rolling means hitting them one by one.
One more thing that’s easy to forget: URLs have to be preserved exactly… Hexo’s abbrlink short link (crc16/dec, permalink is p/:abbrlink/) became a Hugo slug, then became work.id on the new site — /p/{id}/ survived three generations of that old link. Not even the trailing slash can change — Artalk/Valine historical comments are stored under the path /p/{id}/ with the slash, and missing one character means losing comments. To that end, vns-next had to disable the framework’s built-in trailing-slash redirect and take over itself; I posted the full comments from that tug-of-war in the post about convention files.
Finally, one intermediate state. The bbps site is also output: 'export', with next.config.ts only 12 lines, and real-time data simply bypassed with client-side polling:
useEffect(() => {
fetchStats()
const id = setInterval(fetchStats, 30000)
return () => clearInterval(id)
}, [fetchStats])Hit an external API every 30 seconds — good enough… Static shell + client polling, effortless.
A month of moving things around, and it really boiled down to one question: is there anything on this site that absolutely has to live on a server? Think it over and the answer is obvious—there’s no mystery to it. Anyway, I’m out~
