Hidden Cost of AI-Generated Code: What Breaks When You Go to Production

TL;DR
- The majority of Next.js production issues are self-inflicted patterns — CSS chunk ordering, bloated bundles, sequential fetches, duplicate queries, and hydration mismatches all have straightforward fixes once you know what to look for
- Run the bundle analyzer before touching any code — it will show you which server-safe libraries leaked into your client bundle and exactly how much they cost you
- A single misplaced use client on a parent component ships your entire component tree to the browser; push it to leaf nodes only and keep data fetching, layout, and formatting on the server
- Sequential await calls in server components create accidental data waterfalls — wrapping independent fetches in Promise.all() or Suspense boundaries can cut page load time from 5+ seconds to under 2
- Hydration errors only surface in production because the dev server masks mismatches — the three causes are browser API access during render, non-deterministic values, and invalid HTML nesting; each has a one-line fix
Introduction
Recently users on reddit analyzed 500 active Next.js GitHub issues and posted their findings. The most uncomfortable conclusion: the majority of Next.js production issues aren’t framework bugs. They’re patterns — the same five or six mistakes, reproduced across hundreds of codebases by developers who learned React in a client-side world and never fully adjusted their mental model.
Next.js gives you server components by default, built-in caching, streaming, and a bundle pipeline that can ship almost nothing to the browser. Most teams use about 20% of that. The rest gets accidentally disabled, worked around, or never touched — and production pays the price.
This guide covers the five Next.js production issues that show up most often and hurt most.
Diagnose Before You Fix — Run the Bundle Analyzer First
Before changing a single line, you need to know what your production build is actually shipping to the browser. Guessing wastes time. The bundle analyzer tells you exactly which libraries are in your client bundle and how large they are.
If you’re on Turbopack (Next.js v16.1+):
ANALYZE=true npx next buildIf you’re on webpack:
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})Both generate an interactive visual map of your compiled output. Open it and look for two things: what’s there that shouldn’t be, and what’s larger than you expected. That map is your diagnostic baseline for every problem in this guide.
What a Healthy Bundle Looks Like vs. a Bloated One
A healthy bundle has one large, expected block — ReactDOM client. It’s heavy because it has to be; it’s what runs your React code in the browser. Everything else should be small, scoped UI components and their direct dependencies.
Here’s what a problem looks like:
Page: /dashboard
ReactDOM client — 280KB ✓ expected
date-fns — 16.98KB ✗ should not be here
Total (compressed): 317KBdate-fns is a date formatting library. It has no browser APIs, no event listeners, nothing that requires the client. If it’s in your bundle, it means the component using it was marked use client — or is a child of one that was. Removing that single misplacement drops the compressed bundle to 304KB.
That’s the pattern you’re looking for: server-safe code that leaked into the client. Once you can see it, fixing it is straightforward. The next four sections show you exactly where these leaks come from.
Problem 1 — Production CSS Breaks When Dev CSS Looks Fine
This is the single most-reported category in an analysis of 500 active Next.js GitHub issues. Not hydration errors. Not build failures. CSS that works perfectly in development and breaks the moment you deploy.
The frustrating part: nothing in your code changed between environments. The bug isn’t in your styles — it’s in how production builds handle them.
Why CSS Import Order Is Non-Deterministic in Production
The Next.js dev server loads stylesheets sequentially, in the order your components mount. Predictable, consistent, easy to reason about. Production builds work differently — webpack splits your code into chunks and optimises them for parallel loading.
That process doesn’t guarantee the order your CSS files are evaluated, which means styles that override each other correctly in dev can load in the wrong sequence in production.
The most common trigger: importing global CSS from inside a component rather than from the root layout.
/* styles/button.css */
.btn { background: blue; }
/* styles/overrides.css */
.btn { background: red; } /* intended to win */In dev, overrides.css loads after button.css — red wins. In production, chunk ordering puts button.css last — blue wins. The styles didn’t change. The load order did.
Three Fixes, Ranked by Permanence
Fix 1 — Scope your styles with CSS Modules (permanent)
CSS Modules generate unique class names per component, eliminating cascade conflicts entirely. No two components can accidentally override each other’s styles because the class names are never shared.
/* button.module.css */
.btn { background: blue; }
import styles from './button.module.css'
export function Button() {
return <button className={styles.btn}>Click</button>
}Fix 2 — Move all global CSS imports to your root layout (immediate)
Global stylesheets should only be imported once, at the top of the tree. If you’re importing globals.css anywhere other than app/layout.tsx, move it there now.
// app/layout.tsx — correct
import './globals.css'
// SomeComponent.tsx — wrong, causes ordering issues
import '../styles/globals.css'Fix 3 — Check Tailwind’s content paths (diagnostic)
If you’re using Tailwind and classes are missing in production but present in dev, your content config isn’t covering all component directories. Tailwind purges unused classes at build time — if a path is missing, the class gets stripped.
// tailwind.config.js
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}', // missing this = classes disappearing
],
}Start with Fix 3 to rule out Tailwind purging. If that’s not the cause, Fix 2 costs five minutes. Fix 1 is the permanent solution — migrate to CSS Modules as you touch components.
Problem 2 — The “use client” Virus Is Bloating Your JavaScript Bundle
By default, every component in the Next.js App Router is a server component. Its JavaScript never reaches the browser. No bundle cost, no hydration overhead — the server handles it and sends HTML. That’s the default you want to preserve as much as possible.
Here’s how developers accidentally throw it away.
A page is working fine as a server component. Someone needs to add a like button — a single piece of interactivity. React throws an error: useState only works in a client component, add use client. The developer adds it to the top of the page file, the error disappears, the button works.
What they didn’t notice: every component in that page’s subtree now ships its JavaScript to the browser. The date library, the card layout, the data formatting utilities — all of it, bundled and sent to the client for no reason.
That’s the virus. One directive in the wrong place, silently inflating your bundle.
How to Spot It in Your Bundle Analyzer Output
Open your bundle analyzer and navigate to the affected route. If you see libraries that have no business running in a browser — date formatters, markdown parsers, database clients, server-side utilities — they’ve leaked through a misplaced use client.
In the example from our codebase, date-fns appeared at 16.98KB in the client bundle on a page that only displayed a formatted date. Nothing on that page required the browser at all.
The Leaf Node Pattern — Correct Extraction with Code
The fix is to push use client as far down the component tree as possible — to the specific component that actually needs interactivity, not the page that contains it.
Before — virus pattern:
'use client' // infects the entire page tree
import { format } from 'date-fns'
import { useState } from 'react'
import { Heart } from 'lucide-react'
export default function BlogPost({ post }) {
const [liked, setLiked] = useState(false)
return (
<article>
<h1>{post.title}</h1>
<time>{format(post.date, 'PPP')}</time>
<button onClick={() => setLiked(!liked)}>
<Heart />
</button>
</article>
)
}After — leaf node pattern:
// app/blog/[slug]/like-button.tsx
'use client' // isolated to the one component that needs it
import { useState } from 'react'
import { Heart } from 'lucide-react'
export function LikeButton() {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(!liked)}><Heart /></button>
}
// app/blog/[slug]/page.tsx — stays a server component
import { format } from 'date-fns' // no longer in client bundle
import { LikeButton } from './like-button'
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<time>{format(post.date, 'PPP')}</time>
<LikeButton />
</article>
)
}date-fns disappears from the client bundle entirely. The page stays a server component. The only thing the browser receives is the LikeButton — which is the only thing that ever needed to be there.
The rule is simple: if a component doesn’t use browser APIs, event handlers, or React hooks, it has no business being a client component.
Problem 3 — Sequential Data Fetches Are Adding Seconds to Every Page Load
Here’s a real timing breakdown from a course platform page with four data fetches:
| Fetch | Time |
| Featured course | 1.5s |
| All courses | 1.5s |
| Instructors | 1.21s |
| Reviews | 1.0s |
| Total | 5.2s |
None of these depend on each other. The reviews don’t need the instructors. The courses don’t need the featured course. Yet because each await blocks the next, the page waits the full 5.2 seconds before rendering anything. That’s a data waterfall — and it’s entirely accidental.
How Waterfalls Form in Server Components
The pattern is easy to write without realising the cost:
export default async function CoursePage() {
const featured = await getFeaturedCourse() // 1.5s
const courses = await getAllCourses() // starts at 1.5s
const instructors = await getInstructors() // starts at 3.0s
const reviews = await getReviews() // starts at 4.21s
return <>{/* all data available at 5.2s */}</>
}Each await resolves before the next line executes. Sequential by design, waterfall by accident.
Promise.all vs. Suspense Boundaries — Decision Table
Two fixes. Which one to use depends on whether you want streaming.
Fix 1 — Promise.all (parallel, no streaming):
export default async function CoursePage() {
const [featured, courses, instructors, reviews] = await Promise.all([
getFeaturedCourse(),
getAllCourses(),
getInstructors(),
getReviews(),
])
return <>{/* all data available at ~1.5s */}</>
}All four fetches fire simultaneously. Total wait time drops to the slowest individual fetch — roughly 1.5 seconds. The page still waits for all data before rendering anything, but the wall clock time is cut by more than half.
Fix 2 — Suspense boundaries (parallel + streaming):
export default function CoursePage() {
return (
<>
<Suspense fallback={<Skeleton />}>
<FeaturedCourse /> {/* fetches and renders as soon as ready */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<CourseList /> {/* independent, doesn't wait for above */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Reviews /> {/* streams in at ~1s */}
</Suspense>
</>
)
}Each component fetches its own data and renders the moment it’s ready. Reviews stream in at 1 second without waiting for courses. Users see content progressively instead of a blank page for 5 seconds.
| Promise.all | Suspense boundaries | |
| Setup complexity | Low | Medium |
| Streaming | No | Yes |
| Perceived performance | Good | Best |
| Best for | Simple pages, equal-speed fetches | Mixed-speed sections, long pages |
Default to Suspense boundaries in the App Router. Use Promise.all when the page is simple enough that streaming adds no meaningful UX benefit.
Problem 4 — generateMetadata and Your Page Are Both Hitting the Database
This one doesn’t throw an error. There’s no warning in the console, no performance alarm. Your page works correctly. You just don’t realise you’re paying for every database query twice.
Here’s the scenario: you have a course page that needs the course data for two things — the SEO metadata (title, description) and the page itself (rendering the content). So you fetch it in generateMetadata and fetch it again in the page component.
export async function generateMetadata({ params }) {
const course = await getCourse(params.id) // DB hit #1
return { title: course.title, description: course.description }
}
export default async function CoursePage({ params }) {
const course = await getCourse(params.id) // DB hit #2 — same row
return <CourseContent course={course} />
}Check your server logs and you’ll see the same query firing twice on every page load. At scale, that doubles your database read volume for no reason.
Using React cache() to Deduplicate Server Fetches
React.cache() memoizes a function’s return value for the duration of a single server request. Wrap your fetch function once — every call to it within the same request returns the cached result instead of hitting the database again.
import { cache } from 'react'
export const getCourse = cache(async (id: string) => {
return await db.course.findUnique({ where: { id } })
})
Now both generateMetadata and the page component can call getCourse() freely. The database is queried once. The second call costs nothing.
export async function generateMetadata({ params }) {
const course = await getCourse(params.id) // DB hit #1
return { title: course.title, description: course.description }
}
export default async function CoursePage({ params }) {
const course = await getCourse(params.id) // returns cached result
return
}One important distinction: React.cache() is per-request, not global. The cache is discarded after each server render cycle. There’s no risk of one user seeing another user’s data.
Problem 5 — Hydration Errors That Only Surface in Production
Hydration is the process where React takes the HTML rendered by the server and attaches event listeners and state to it on the client. For this to work, the client render must produce output identical to the server render. When it doesn’t, you get a hydration error.
In development, Next.js is forgiving — hot module reloading masks many mismatches. In production, React is strict. The mismatch surfaces as a console error, a visible UI flash, or in severe cases, a broken page.
The Three Most Common Hydration Mismatch Causes
1. Browser APIs accessed during render
window, localStorage, navigator — none of these exist on the server. If your component reads them during the render phase, the server produces different HTML than the client.
// breaks — window doesn't exist on server
export function Banner() {
return <p>Your OS: {window.navigator.platform}</p>
}
// fix — move to useEffect, runs client-only
export function Banner() {
const [platform, setPlatform] = useState('')
useEffect(() => { setPlatform(window.navigator.platform) }, [])
return <p>Your OS: {platform}</p>
}2. Non-deterministic values
Math.random(), Date.now(), and new Date() produce different values on the server and client, causing guaranteed mismatches.
// breaks — different value server vs. client
<div id={`item-${Math.random()}`}>
// fix — use React's stable ID hook
const id = useId()
<div id={id}>3. Invalid HTML nesting
Browsers silently auto-correct invalid nesting during HTML parsing — for example, a <div> inside a <p> gets moved outside it. React sees the corrected DOM and finds it doesn’t match its virtual DOM, triggering a mismatch.
// breaks — div cannot be a child of p
<p><div>Some content</div></p>
// fix
<div><div>Some content</div></div>How to Debug the React Hydration Error Message
React 18 tells you exactly what mismatched. The console error shows the component stack and a diff of expected vs. received output:
Error: Hydration failed because the server rendered HTML didn’t match the client.
Expected server HTML to contain a matching <div> in <p>
Work up the component stack from the flagged element until you find where server and client diverge. In most cases it’s one of the three causes above.
One escape hatch exists: suppressHydrationWarning={true} on an element tells React to ignore mismatches on that node. Use it only for genuinely unavoidable cases — browser-injected attributes like class added by extensions. Using it to silence real bugs just hides the problem.
Frequently Asked Questions
The Next.js dev server loads stylesheets sequentially, but production builds use webpack chunk splitting which doesn’t guarantee CSS load order. If your styles rely on cascade order between files, production can load them in a different sequence and break your overrides. The fix is to use CSS Modules for component-level styles and import all global CSS exclusively from your root layout.tsx.
The “use client” virus happens when a developer adds use client to a parent or page-level component to enable one interactive element, which silently marks the entire component subtree as client-side code — shipping all of it to the browser. The fix is to extract interactive elements into isolated leaf node components with their own use client directive, leaving the rest of the page as a server component.
Replace sequential await calls with Promise.all() to fire all independent fetches simultaneously, or co-locate each fetch inside its own component and wrap it in a <Suspense> boundary to enable streaming. The Suspense approach is preferred in the App Router because it lets each section render as soon as its data is ready, rather than waiting for all fetches to complete.
If you fetch the same data in both generateMetadata and your page component, Next.js executes both functions in separate contexts with no automatic deduplication — resulting in two identical database queries per page load. Wrap your fetch function in React’s cache() utility to memoize the result for the duration of the request, so both functions share a single database call.
Hydration errors are caused by a mismatch between the HTML the server renders and what React produces on the client. The most common causes are accessing browser APIs like window during render, using non-deterministic values like Math.random(), and invalid HTML nesting. Move browser API access into useEffect, use useId() for stable IDs, and validate your HTML structure to resolve the mismatch.
Start by running the bundle analyzer to identify which libraries are in your client bundle that don’t need to be there. The most common cause of bundle bloat is a misplaced use client directive that pulls server-safe libraries — date formatters, data utilities, markdown parsers — into the browser unnecessarily. Moving those components back to the server or extracting only the interactive parts into leaf node client components typically produces the largest gains.
The Pattern Is Always the Same
The fastest place to start is the bundle analyzer. Run it against your production build today and you’ll likely find at least one library that has no business being in the browser.
If you want to go deeper, the Next.js performance optimization ebook covers these patterns in detail with more complete code walkthroughs. And if you’re dealing with these issues at scale, our Next.js performance optimization service is worth a look.
