I’ve been writing several Next.js projects back to back lately. The features themselves went smoothly, but they all got stuck on the same class of problems: next dev works fine locally, then everything blows up the moment you build or deploy. Not one of these issues is “the code is wrong” — they’re all “the rendering mode isn’t set up right,” and the error messages usually point somewhere else entirely, which makes them a huge time sink…

Here I’ll write down a few I’ve run into, with file line numbers where I can~

Pitfall 1: There’s no database at build time

vns-next is a Galgame resource site, Prisma + Postgres, self-hosted via Docker Compose. It runs fine locally, but the moment it hits the multi-stage build in the Dockerfile, it explodes. The reason is that Next by default statically pre-renders RSC pages during the next build phase, and the builder layer has no database container at all, so any page that queries the DB is bound to throw during build.

The fix is in commit b07e20d: add one line export const dynamic = "force-dynamic" to the root layout, plus feed.xml, llms.txt, and index.xml to sitemap.ts to declare three route handlers as dynamic in sync.

// src/app/layout.tsx:11-13
// 全站动态渲染:内容页在请求时查库(Docker 构建环境没有数据库,
// 静态预渲染会在 build 阶段连库失败);热点查询已有内存缓存兜底
export const dynamic = "force-dynamic";

Worth noting is the cost: git show --stat b07e20d shows 5 files changed, 16 insertions(+), where the actual declarations are just one line in each of the 5 files, and the other 11 lines are all comments and blank lines. That’s all it takes to flip the whole site from static to dynamic. Conversely, it also means you could flip the whole site to dynamic without even noticing…

I think the verification method is more worth copying than the fix itself: the commit body says “reproduced the build locally with a dead DATABASE_URL and it passed.” That is, use an unreachable DATABASE_URL to reproduce the Docker build environment locally — first watch it fail, then verify it works. Otherwise, after making the change, all you can do is push to the server and gamble~

If you don’t want to convert the whole site to dynamic, there are two other paths. acg-site wraps generateStaticParams entirely with try/catch:

// src/app/video/tag/[slug]/page.tsx:25-36
export async function generateStaticParams() {
  try {
    const popularTags = await prisma.tag.findMany({
      where: { videos: { some: { video: { status: "PUBLISHED" } } } },
      take: 50,
      orderBy: { videoCount: "desc" },
      select: { slug: true },
    });
    return popularTags.map((tag) => ({ slug: tag.slug }));
  } catch {
    return [];
  }
}

If it can’t reach the DB at build time, it pre-renders 0 pages and everything degrades to runtime generation, but at least the build won’t go red. thdl takes the other path: the homepage declares revalidate = 60, does a let latest = [] first as a base before querying, then uses try{}catch{latest = []} to catch DB failures. src/app/(site)/page.tsx:18-27 is that implementation.

Pitfall 2: Writing revalidate doesn’t mean it’s ISR

This one is the most infuriating, because it doesn’t throw an error~

In thdl's src/app/(site)/resources/page.tsx:6, export const revalidate = 30 is written in all seriousness, but line 11 of the same file is const sp = await searchParams. A page that uses searchParams is judged as dynamically rendered under Next 16, so that revalidate is just an ineffective declaration.

[slug]/page.tsx:17 in the same repo also has revalidate = 30 written, calls getSession() inside, and src/lib/get-session.ts internally is auth.api.getSession({ headers: await headers() }). Reading headers(), the route still gets marked dynamic, and ISR likewise doesn’t take effect.

An even more absurd example is in bbps. This site is a output: 'export' pure static export, and git show 99b11c53^:src/app/blog/[id]/page.tsx shows export const revalidate = 600 right there on line 7. Note that at that time this file was still actively pulling data from the WordPress API — this line was written specifically for the WP fetch back then, not some leftover junk from an earlier era; it’s just that under output: 'export' it never took effect from the very first day it was written…

What these declarations have in common: no error, no warning, tsc passes, and they just silently do nothing… To confirm whether it’s actually in effect, you still have to look at the marker in front of the route in the next build output.

Pitfall 3: The hydration hat-trick

I’ve categorized hydration mismatch by symptom, and it basically never escapes these three:

SymptomRoot causeMy solution
Persisted state briefly flashes the default value on the first framelocalStorage doesn’t match the SSR first framemounted gating, always return the default value before mount
Second-level timers / timestamps report mismatchServer and client Date.now() are inevitably differentsuppressHydrationWarning
White screen or error when switching themesnext-themes injects a class into <html><html suppressHydrationWarning>

The first one: vns-next uses zustand persist to store settings; the solution is extracted into a hook:

// src/stores/settings.ts:57-70
/**
 * 水合安全地读取单个设置项:挂载前恒返回默认值,
 * 避免 localStorage 持久化状态与 SSR 首帧不一致(hydration mismatch)。
 */
export function useSettingValue<K extends keyof SettingsData>(key: K): SettingsData[K] {
  const value = useSettings((s) => s[key]);
  const [mounted, setMounted] = useState(false);
  useEffect(() => { setMounted(true); }, []);
  return mounted ? value : defaultSettings[key];
}

The second one can’t be cured. That footer timer showing “running for X days X hours X minutes X seconds” — the server-rendered seconds can’t match the client’s, that’s just physics. src/components/layout/running-time.tsx:30-31 just accepts it:

// 挂载前渲染占位,避免 SSR 与客户端时间不一致导致的 hydration 报错
return <span suppressHydrationWarning>{text ?? "…"}</span>;

But I treat this attribute as a consumable — vns-next is used in only 3 places across the whole site (layout.tsx:38, running-time.tsx:31, dashboard/work-form.tsx:319). After all, it just mutes the warning; the problem itself is still there.

The third kind is the most common. In thdl‘s layout.tsx, suppressHydrationWarning (:36) and next-themes’ ThemeProvider (:41) are a matched pair — miss one and it throws. acgn-flow's ef73bb4 listed five at once: add attributes to layout.tsx:109's body, swap motion.span for a plain span in the header, replace the header’s conditional rendering with CSS max-h/opacity, add a mounted gate to the auth UI, and also bolt mounted onto PageWrapper and FadeIn. Two minutes later 46c6101 patched yet another next-themes mismatch in settings-panel — clearly this thing loves to slip through…

Static sites aren’t immune either. In bbps's src/contexts/locale-context.tsx:96-104, the effect has to re-read localStorage:

// 首次挂载:按偏好跳转(直接读 localStorage,避免 hydration 闭包捕获到服务端快照 'system')
useEffect(() => {
  const stored = localStorage.getItem(STORAGE_KEY) as LocalePref | null
  // ...
}, [])

The root cause is that getPrefServerSnapshot() in the same file’s :74-76 always returns 'system' — that useSyncExternalStore value gets captured by the effect’s closure, so using it directly always gives you the server snapshot.

One more, even colder: home that time in 68e9570, static export baked the build-time year into the footer HTML, so the moment the client crossed into a new year it mismatched, and the only fix was sticking a suppressHydrationWarning on app-footer.tsx:38. That site later migrated off Next entirely, so nobody has touched that attribute since…

Pitfall 4: dayjs locale on the SSR side registers on a different instance

This one’s the sneakiest, because on the client everything looks perfectly fine~

Commit 99be0f1 in acg-site: under Next’s server-side ESM/CJS interop, the side-effectful import "dayjs/locale/zh-cn" registers the locale on a different dayjs instance than the one your business code uses. dayjs.locale("zh-cn") can’t find it and falls back to en, so the HTML that SSR spits out says “16 days ago” instead of “16 天前”.

The fix is triple insurance:

// src/lib/format.ts:1-8
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
// 直接 import locale 对象并手动 register;副作用 import "dayjs/locale/zh-cn" 在 Next.js
// server 端的 ESM/CJS interop 下会把 locale 注册到另一个 dayjs 实例上,导致 fromNow() 始终输出英文
import zhCnLocale from "dayjs/locale/zh-cn";

dayjs.extend(relativeTime);
dayjs.locale(zhCnLocale, undefined, true);

Then add one more safety net at the :33 call site: return dayjs(date).locale("zh-cn").fromNow();. For libraries that do global registration via module side effects, I now always take a second look in RSC…

Pitfall 5: entrance animations can leave the whole page stuck at half opacity

vns-next's src/app/template.tsx is 14 lines total, and the comment spells out why framer-motion isn’t used:

Using CSS animations (tw-animate-css) instead of framer-motion: opacity/transform run on the compositor thread,
so when rAF is throttled/frozen (background tabs, embedded webviews, low-end devices) the whole page won’t get stuck in a half-transparent intermediate state,
and when animations are unavailable the element’s static state is already its final visible state. prefers-reduced-motion degrades via motion-reduce.

JS-driven entrance animations mostly use rAF to push opacity from 0 to 1; once the browser throttles or even freezes rAF, the animation stalls at 0.3 and never moves again, leaving the user staring at a half-transparent page. CSS animations run on the compositor thread and don’t suffer from this, and even in the worst case where animations are completely unavailable, the element’s static style is already the final visible state.

This reasoning shows up 5 times in this repo, and commit 123f722's body records this fix too~

Two Next 16 renames, noted in passing

One is middleware being renamed to proxy. The file in vns-next is just src/proxy.ts, the exported function is also called proxy, and src/middleware.ts doesn’t exist in the repo at all. The comment at the top of the file, :4, reads “trailing-slash self-managed redirect (Next 16 renamed middleware to proxy)”.

The other is that revalidateTag now requires an explicit second argument (profile); omitting it takes the deprecated warning path. I hit this while upgrading another project to Next 16 — both styles coexist in the same repo, one passing an object and one passing a string. Both are legal, it’s just inconsistent style~

Anyway, I’ll most likely step on this again, so I’m writing it down first…