Next.js 16 與 Tailwind CSS v4:AI 編碼助手常踩的 5 個陷阱及修正提示詞
Next.js 16 + Tailwind v4: 5 traps AI coding assistants keep hitting (and the prompts that fix them)
文章整理 AI 編碼助手在 Next.js 16 與 Tailwind CSS v4 專案中常遇到的五類問題,逐一説明成因、修正方法,並提供可直接套用的提示詞。
A fresh create-next-app in late 2026 gives you Next.js 16 and Tailwind CSS v4. Most AI coding assistants learned on years of Next 13–15 and Tailwind v3 code. That gap produces the same handful of failures again and again, and they often show up only at npm run build, or only on a laptop set to dark mode.
Below are five of them. For each one you get the symptom (with the exact error text where there is one), why the assistant gets it wrong, the fix, and a short prompt you can paste into Cursor, Claude Code, or ChatGPT.
Versions used: create-next-app 16.4.2 (Next.js 16.4.2, React 19.3), Tailwind CSS 4.3, Node 20.
Trap 1: npm run build fails on a brand-new app (cacheComponents)
Symptom. npm run dev looks fine, then the build fails with one of these:
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.
Route "/expenses": Next.js encountered the unstable value `new Date()` in a Client Component.
This value would be evaluated during the prerender, instead of recomputed on each visit.
Why the assistant gets it wrong. With its recommended defaults (--yes), create-next-app 16.4 now writes this into next.config.ts:
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
turbopack: { /* … Tailwind CSS loader … */ },
};
The Next.js caching docs say it directly: new App Router apps made with the recommended defaults "already have Cache Components and Partial Prefetching enabled." The assistant doesn't know this. It reaches for the Next 13–15 habit of export const dynamic = "force-dynamic", which Cache Components rejects, or it puts new Date() in a component that now gets prerendered.
In our test runs, both of our sample apps failed npm run build at this point. One failed on the dynamic export and one on a new Date() default for a date input. The fix that worked was removing the two config lines.
Fix, option A: opt out (fine for a localStorage app or a small landing page):
// next.config.ts
const nextConfig: NextConfig = {
// removed: cacheComponents: true,
// removed: partialPrefetching: true,
turbopack: { /* keep this block: it loads Tailwind */ },
};
You can also scaffold without it. In our check, the 16.4.2 CLI accepted --no-cache-components even though --help doesn't list it:
npx create-next-app@latest my-app --ts --tailwind --eslint --app --use-npm --yes --no-cache-components
After either change, delete .next/ and rebuild.
Fix, option B: keep Cache Components and do it its way. Remove the dynamic export. The migration guide says to drop force-dynamic, because uncached work already runs at request time. Then put the request-time part behind <Suspense> and await connection():
import { Suspense } from "react";
import { connection } from "next/server";
async function SignupCount() {
await connection(); // render at request time
const total = await getTotal();
return <p>{total} people joined</p>;
}
export default function Page() {
return (
<Suspense fallback={<p>…</p>}>
<SignupCount />
</Suspense>
);
}
Prompt:
npm run buildfails with "Route segment config "dynamic" is not compatible with nextConfig.cacheComponents" or "encountered the unstable valuenew Date()". This is Next.js 16, andcreate-next-appenabledcacheComponentsby default. Opennext.config.ts, removecacheComponents: trueandpartialPrefetching: true, and keep theturbopackblock. Make sure no component callsnew Date(),Date.now(), orMath.random()while rendering. Delete.next/and re-run the build until it passes.
Trap 2: new Date() and localStorage during render (and the lint errors after you fix it)
Symptom. Hydration warnings. Data that flashes and disappears. A "today" date that is one day off. A default month that is frozen at whatever month you ran the build in. Once you fix those, npm run lint fails with react-hooks/set-state-in-effect or react-hooks/purity.
Why the assistant gets it wrong. Its go-to patterns are useState(new Date()), useState(() => JSON.parse(localStorage.getItem(...))), and new Date().toISOString().slice(0, 10) for "today." All three either run during server prerendering or quietly use UTC. A common reflex is a mounted flag that renders "Loading…" until the effect runs. In our test runs that didn't help. The useState initializers still ran during prerender, and the build still failed.
Two more things from the test runs. A month default set during render ended up baked into the static HTML. Then the standard fix (empty state, load in useEffect) produced 10 react-hooks/set-state-in-effect errors under the ESLint config that Next 16 ships, which includes eslint-plugin-react-hooks 7.
Fix. Start with empty state and read browser values in an effect or an event handler. That is also the "[defer]" option Next's own error message suggests.
"use client";
const [expenses, setExpenses] = useState<Expense[]>([]);
const [date, setDate] = useState("");
useEffect(() => {
setExpenses(loadExpenses()); // reads localStorage on the client only
setDate(todayISO());
}, []);
Build "today" from local time, not toISOString():
export function todayISO(): string {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
For the lint rule you have two honest options. If loading from localStorage once on mount is intentional, turn off just that rule and leave a comment saying why:
// eslint.config.mjs
{
// Loading from localStorage once on mount is intentional in this app.
rules: { "react-hooks/set-state-in-effect": "off" },
},
Or design the effect away: derive the value, let CSS handle it (see Trap 4), or do the work in a handler. For react-hooks/purity, take Date.now() out of the component body and compute time-based values on the server, in a Route Handler or a server-only lib/ helper.
Prompt:
House rule for this project: never read
localStorageor callnew Date()/Date.now()during render or in auseState(...)initial value. Start state empty ([]/"") and load it inuseEffector in a click/submit handler. Store dates as localYYYY-MM-DDand never usetoISOString()for "today." A mounted-flag "Loading…" gate is not enough on its own. Then runnpm run lintandnpm run buildand fix what they report.
Trap 3: The scaffold's CSS makes your app unreadable in dark mode, and borders look black
Symptom. On a computer set to dark mode, the page is near-black with light text, and your new bg-white cards show light text on white. Every border looks dark.
Why the assistant gets it wrong. It builds on top of the template's app/globals.css without reading it. In 16.4.2 that file includes:
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
}
On top of that, Tailwind v4 changed the default border color from gray-200 to currentColor (see the upgrade guide's "Default border color" section). v3-trained assistants assume borders are a soft gray. In our test runs, both problems showed up in the final QA pass: the UI was unreadable on a dark-mode OS, and the borders defaulted to black.
Fix. Replace globals.css with a minimal version and set colors on <body> yourself:
@import "tailwindcss";
@layer base {
*, ::before, ::after {
border-color: var(--color-neutral-200);
}
}
<body className="bg-neutral-50 text-neutral-900">
Prompt:
Text is unreadable on a computer set to dark mode, and all borders look black. In
app/globals.css, remove the default:root,prefers-color-scheme, andbodyrules. Keep@import "tailwindcss";and add@layer base { *, ::before, ::after { border-color: var(--color-neutral-200); } }. Putbg-neutral-50 text-neutral-900on<body>. Tailwind only, no UI library.
Trap 4: The dark-mode toggle does nothing
Symptom. You click the toggle and nothing changes, or the page only goes dark when the OS is already dark. After a refresh you get a white flash.
Why the assistant gets it wrong. In Tailwind v3 you set darkMode: "class" in tailwind.config.js. In v4, dark: follows prefers-color-scheme by default, and class-based toggling is configured in CSS. Assistants still write the v3 config, or toggle the class on <body> instead of <html>. When we compiled Tailwind 4.3 without the override, dark: utilities came out inside a prefers-color-scheme media query, so a .dark class had no effect. Our budget-app test run hit exactly this: the toggle did nothing until we added @custom-variant dark.
Fix. This one line is from the Tailwind dark-mode docs:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Toggle the class on document.documentElement. Apply the saved theme before first paint with a small inline script, and let CSS pick the icon so there's no React state to mismatch on hydration:
// app/layout.tsx
const themeScript = `try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}`;
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
…
</html>
// components/ThemeToggle.tsx
"use client";
export default function ThemeToggle() {
function toggle() {
const isDark = document.documentElement.classList.toggle("dark");
try { localStorage.setItem("theme", isDark ? "dark" : "light"); } catch {}
}
return (
<button type="button" onClick={toggle} aria-label="Toggle dark mode">
<span className="dark:hidden">🌙</span>
<span className="hidden dark:inline">☀️</span>
</button>
);
}
The CSS-picked icon also avoids the set-state-in-effect lint error from Trap 2.
Prompt:
The dark-mode toggle does nothing. This is Tailwind v4: there is no
darkModesetting in a JS config. Add@custom-variant dark (&:where(.dark, .dark *));toglobals.css. Toggle thedarkclass ondocument.documentElement, not<body>. Add an inline<head>script that applies the saved theme before paint, and addsuppressHydrationWarningon<html>. Choose the icon withdark:hidden/hidden dark:inlineinstead of React state. Then add missingdark:classes to cards, inputs, and the header.
Trap 5: Tailwind v3 habits (gradients, renamed utilities, the JS config)
Symptom. Shadows, corners, and blurs look slightly off. A custom brand color or gradient you asked for doesn't appear. The assistant creates a tailwind.config.js or adds @tailwind base; lines.
Why the assistant gets it wrong. Most of the Tailwind in its training data is v3. A common trap is a mix of v3 and v4 syntax:
| v3 habit | v4 |
|---|---|
bg-gradient-to-r |
bg-linear-to-r (renamed in v4.0) |
shadow-sm / shadow
|
shadow-xs / shadow-sm
|
rounded-sm / rounded
|
rounded-xs / rounded-sm
|
outline-none |
outline-hidden |
ring (3px) |
ring-3 |
@tailwind base; … |
@import "tailwindcss"; |
tailwind.config.js theme |
@theme { … } in CSS (JS config only loads via @config) |
To be fair to bg-gradient-to-r: when we compiled it with Tailwind 4.3, it still produced CSS as a legacy alias. The v4 docs only use bg-linear-* (along with the new bg-radial-* and bg-conic-*), so standardize on that. A more common reason a gradient "doesn't show up" is that its colors are defined in a tailwind.config.js that v4 never loads, so classes like from-brand are never generated.
Fix. Define custom colors in CSS:
@import "tailwindcss";
@theme {
--color-brand: #7c3aed;
}
<span className="bg-linear-to-r from-brand to-pink-600 bg-clip-text text-transparent">
Ship it
</span>
Prompt:
This project uses Tailwind CSS v4, not v3. Don't create
tailwind.config.jsor use@tailwinddirectives. Theme values go in@themeinglobals.css. Use v4 names:bg-linear-to-*instead ofbg-gradient-to-*,shadow-xs/rounded-xsfor the old-smsizes,outline-hiddeninstead ofoutline-none. Gradient text needsbg-clip-text text-transparent. Search the codebase for v3 names and replace them.
Two smaller ones worth adding to your rules
-
Internal links.
<a href="/#features">triggers@next/next/no-html-link-for-pagesinnpm run lint. In one test run it produced 6 errors. Use<Link href="/#features">fromnext/link. -
searchParamsis a Promise. Type it assearchParams: Promise<Record<string, string | string[] | undefined>>andawaitit. Assistants still destructure it synchronously.
Make it stick
Fixing these one at a time works, but the cheaper move is to stop the assistant from writing them in the first place. Put the house rules in a file your tool reads on every request: AGENTS.md at the project root (Cursor reads it, and so do recent Claude Code versions when there's no CLAUDE.md) or a .cursor/rules/*.mdc file with alwaysApply: true. create-next-app 16.4 already generates an AGENTS.md that tells agents to read the bundled docs in node_modules/next/dist/docs/. Add your rules below that block. Then make npm run lint && npm run build the final checkpoint of every session, because that is where most of these failures show up.
If you want these as ready-to-paste prompts, there's a free sample with four of them: the scaffold, the Tailwind v4 dark-mode toggle, and the build-failure and colors/gradients rescues. It's here: https://shipinanhour.gumroad.com/l/qfzgeg
來源:DEV Community · dev.to