The Next.js App Router fundamentally changed how you write Next apps. Most teams have migrated โ and most teams have also spent at least one Friday afternoon staring at a mysterious hydration error, wondering if they should have just stuck with Create React App. They shouldn't have. But the App Router does require a mental model shift. Here are the mistakes we see most often in production codebases (and, uh, may have committed ourselves before we knew better).
Mistake 1: Adding 'use client' to everything
The #1 mistake. When you can't figure out why something isn't working, adding 'use client' at the top often fixes it โ but at the cost of losing server rendering entirely. It's the React equivalent of unplugging the wifi router to fix a software bug. Sure, it worked. But at what cost?
What it costs: Larger JS bundles, slower Time to Interactive, no server-side data access.
Fix: Only add 'use client' to components that need browser APIs, event handlers, or React state/effects. Keep data fetching, layout, and static content as Server Components.
// โ Don't do this just because it's easier
'use client'
export default function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState(null)
useEffect(() => {
fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct)
}, [id])
return <div>{product?.name}</div>
}
// โ
Server Component โ no bundle cost, direct DB access
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({ where: { id: params.id } })
return <div>{product?.name}</div>
}
Mistake 2: Waterfall fetches in Server Components
Sequential awaits = sequential round trips = slow pages. Your users didn't come here to watch a loading spinner do a solo performance.
// โ Sequential: 3 ร 200ms = 600ms minimum
export default async function Dashboard() {
const user = await getUser()
const orders = await getOrders(user.id)
const analytics = await getAnalytics(user.id)
// ...
}
// โ
Parallel: max(200ms, 200ms, 200ms) = 200ms
export default async function Dashboard() {
const [user, orders, analytics] = await Promise.all([
getUser(),
getOrders(),
getAnalytics(),
])
// ...
}
Mistake 3: Not using loading.tsx and Suspense
Without Suspense boundaries, the entire page waits for the slowest data fetch before rendering anything.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* This can load independently */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders /> {/* This too */}
</Suspense>
</div>
)
}
Mistake 4: Over-fetching in layouts
Layout components run on every navigation within their scope. If your root layout fetches the user on every page, you're making an extra DB call on every page transition.
// โ Fetches on every navigation
export default async function Layout({ children }) {
const user = await getUser() // called on EVERY page in this layout
return <div><Header user={user} />{children}</div>
}
// โ
Cache the result
export default async function Layout({ children }) {
const user = await getCachedUser() // cached per request
return <div><Header user={user} />{children}</div>
}
// Use React's cache() for deduplication across the request
import { cache } from 'react'
export const getCachedUser = cache(async () => {
return db.users.findFirst({ where: { id: getSessionUserId() } })
})
Mistake 5: Putting secrets in client components
Environment variables without the NEXT_PUBLIC_ prefix are server-only. But if you import a server module from a client component, Next.js will throw an error โ or worse, silently expose the variable.
// โ This will error or leak your secret
'use client'
const API_KEY = process.env.MY_SECRET_KEY // undefined or exposed
// โ
Keep secrets server-side only
// Server Component or Route Handler:
const key = process.env.MY_SECRET_KEY // only runs on server
Use a server-only package to enforce this:
// lib/db.ts
import 'server-only'
// This file can never be imported by a client component
Mistake 6: Skipping generateStaticParams for dynamic routes
If your dynamic routes ([id]) have a finite set of values, pre-render them at build time.
// app/blog/[slug]/page.tsx
// Without this, every blog post is rendered on-demand (slow first load)
export async function generateStaticParams() {
const posts = await getAllPostSlugs()
return posts.map(slug => ({ slug }))
}
// Now all known posts are pre-rendered โ instant loads
Mistake 7: Not setting cache headers on fetch calls
By default, fetch in Server Components is cached. But you need to understand when to revalidate.
// โ Stale forever (default in some Next versions)
const data = await fetch('https://api.example.com/data')
// โ
Revalidate every 60 seconds
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// โ
Never cache (always fresh)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
// โ
Cache until manually invalidated (use with revalidatePath/revalidateTag)
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})
Need a Next.js performance audit? We review codebases and ship the fixes โ and we promise not to judge your 'use client' count. Much. Get in touch.