10 Common Next.js 16 App Router Mistakes (And How to Fix Them)
- fetch() stopped caching by default in Next.js 15 (still true in 16) β add explicit cache directives to every fetch call
- Server-Client props must be JSON-serializable β functions, Map, Set, class instances are not allowed (Server Actions are the exception)
- 'use server' must be the first statement β comments are allowed before it
- fetch() stopped caching by default in Next.js 15 (still true in 16) β opt in with { cache: 'force-cache' } or { next: { revalidate: N } }
- Server Components cannot pass functions or class instances to Client Components β serialize first (Server Actions are the exception)
- 'use server' must be the first statement in a Server Action file or function body (comments are allowed)
- useRouter from next/navigation, not next/router β Pages Router imports break in App Router
- Layouts do not re-render on navigation β useEffect in layouts fires only on full page load
- Server Components cannot access window, localStorage, or document β use Client Components for browser APIs
- params and searchParams are now Promises in Next.js 15+
Stale data on pages
grep -rn 'fetch(' app/ lib/ --include='*.ts' --include='*.tsx' | grep -v 'revalidate\|cache' | head -20curl -s -o /dev/null -w '%{http_code} %{time_total}s' https://your-api.com/dataSerialization error in Server Component
grep -rn 'import.*from.*client\|"use client"' app/ --include='*.tsx' | head -20grep -rn 'function\|=>\|Date.now\|Math.random|new Date' app/ --include='*.tsx' | grep -v 'use client' | head -20useRouter not found or undefined
grep -rn "from 'next/router'" app/ --include='*.tsx' --include='*.ts' | head -10grep -rn "from 'next/navigation'" app/ --include='*.tsx' --include='*.ts' | head -10Image optimization failing
cat next.config.ts | grep -A 10 'images'ls -la public/images/ | head -10Production Incident
Production Debug GuideDiagnose caching, serialization, and routing issues
Next.js 15 (and still true in 16) introduced significant changes to caching defaults, Server Component boundaries, and the App Router architecture. Many of these changes break assumptions carried over from Next.js 13-14. Developers who do not read the migration guide encounter predictable failures: missing data, serialization errors, silent caching bugs, and hydration mismatches.
This article covers the 10 most common mistakes, drawn from production incidents and support threads. Each mistake includes the observable symptom, the root cause, the fix, and the production consequence of leaving it unfixed. The goal is not to explain every Next.js 16 feature β it is to prevent the failures that cost engineering hours.
Mistake 1: Assuming fetch() Caches by Default
This is the most impactful change that landed in Next.js 15 and is still true in Next.js 16. In Next.js 13-14, fetch() responses were cached by default β equivalent to { cache: 'force-cache' }. In Next.js 15/16, fetch() does NOT cache by default. It behaves like native fetch, making a fresh HTTP request on every call.
This means pages that relied on implicit fetch caching now make redundant API calls on every render. If you have ISR (Incremental Static Regeneration) configured, the page revalidates on schedule, but the fetch inside the page makes a fresh request each time instead of returning a cached response.
The fix is explicit: add a cache directive to every fetch call. Use { cache: 'force-cache' } for permanent caching, { next: { revalidate: seconds } } for time-based revalidation, or { cache: 'no-store' } for always-fresh data.
// ============================================ // Mistake 1: fetch() Stopped Caching by Default in Next.js 15/16 // ============================================ // ---- WRONG: No cache directive ---- // This makes a fresh HTTP request on EVERY render // In Next.js 14 this was cached β in Next.js 15/16 it is NOT async function getProducts() { const res = await fetch('https://api.example.com/products') return res.json() } // ---- CORRECT: Explicit cache directive ---- // Option A: Cache permanently (static data) async function getProductsCached() { const res = await fetch('https://api.example.com/products', { cache: 'force-cache', }) return res.json() } // Option B: Time-based revalidation (revalidate every hour) async function getProductsRevalidated() { const res = await fetch('https://api.example.com/products', { next: { revalidate: 3600 }, }) return res.json() } // Option C: On-demand revalidation (revalidate when you call revalidateTag) async function getProductsTagged() { const res = await fetch('https://api.example.com/products', { next: { tags: ['products'] }, }) return res.json() } // Then revalidate on demand: // import { revalidateTag } from 'next/cache' // revalidateTag('products') // Option D: Never cache (always fresh β for user-specific data) async function getUserProfile() { const res = await fetch('https://api.example.com/profile', { cache: 'no-store', }) return res.json() } // ---- Production impact of missing cache directive ---- // Without caching: // - Every page load triggers an API call // - API rate limits are hit faster // - Page load time increases (network latency on every request) // - ISR revalidation is effectively useless // - Server costs increase from redundant compute
Mistake 2: Passing Non-Serializable Data to Client Components
Server Components render on the server and pass props to Client Components. The props must be JSON-serializable: strings, numbers, booleans, plain objects, arrays, null, undefined, and Date objects. Functions, class instances, symbols, and React context providers cannot be passed across the Server-Client boundary.
The error is clear: "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'." But developers encounter subtler versions: passing a Date object that loses its prototype, passing a Map or Set that serializes to an empty object, or passing a callback that silently becomes undefined.
The fix: pass only serializable data. If the Client Component needs a function, define it inside the Client Component or use a Server Action. If it needs a Date, pass the ISO string and reconstruct it on the client.
// ============================================ // Mistake 2: Non-Serializable Props Across Server-Client Boundary // ============================================ // ---- WRONG: Passing a function from Server to Client ---- // This throws: "Functions cannot be passed to Client Components" // app/page.tsx (Server Component) import { ProductCard } from '@/components/product-card' export default async function ProductsPage() { const products = await getProducts() function handleAddToCart(productId: string) { // This function cannot be passed to a Client Component console.log('Added', productId) } return ( <div> {products.map((product) => ( <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} // ERROR: function is not serializable /> ))} </div> ) } // ---- CORRECT: Use a Server Action (the loophole) ---- // Server Actions are serializable references and CAN be passed // app/actions.ts 'use server' export async function addToCart(productId: string) { // Server Action β runs on the server // Can be called from Client Components console.log('Added', productId) } // components/product-card.tsx 'use client' import { addToCart } from '@/app/actions' export function ProductCard({ product }: { product: Product }) { return ( <div> <h3>{product.name}</h3> <button onClick={() => addToCart(product.id)}> Add to Cart </button> </div> ) } // ---- WRONG: Passing Date object ---- // Date serializes to ISO string β loses prototype methods // Server Component export default async function Page() { const event = await getEvent() return <EventCard date={event.date} /> // event.date is a Date object } // Client Component 'use client' export function EventCard({ date }: { date: Date }) { // date.toRelative() may not work β prototype methods lost // date instanceof Date is false after serialization return <p>{date.toLocaleDateString()}</p> } // ---- CORRECT: Pass ISO string, reconstruct on client ---- // Server Component export default async function Page() { const event = await getEvent() return <EventCard date={event.date.toISOString()} /> } // Client Component 'use client' export function EventCard({ date }: { date: string }) { const dateObj = new Date(date) return <p>{dateObj.toLocaleDateString()}</p> } // ---- WRONG: Passing Map or Set ---- // Map serializes to {} β all data is lost // Server Component export default async function Page() { const map = new Map([['key', 'value']]) return <Display data={map} /> // ERROR: Map serializes to {} } // ---- CORRECT: Convert to plain object ---- // Server Component export default async function Page() { const map = new Map([['key', 'value']]) const plain = Object.fromEntries(map) return <Display data={plain} /> } // ---- WRONG: Passing class instance ---- // Class instances lose their methods after serialization class Calculator { add(a: number, b: number) { return a + b } } // Server Component export default async function Page() { const calc = new Calculator() return <CalcDisplay calc={calc} /> // ERROR: methods are lost } // ---- CORRECT: Pass data, not instances ---- // Move the logic to the Client Component or use a Server Action
- Serializable: strings, numbers, booleans, plain objects, arrays, null, undefined, Date (as ISO string)
- Not serializable: functions, class instances, Map, Set, Symbol, React context, DOM nodes
- Server Actions (marked with 'use server') are the exception β they are serializable references and CAN be passed
- If you need a function in a Client Component, define it there or use a Server Action
- If you need a Date, pass the ISO string and reconstruct with new Date(string) on the client
- The error message is clear β but subtle cases (Map, Date prototype) fail silently
Mistake 3: Missing 'use server' Directive in Server Actions
Server Actions must be explicitly marked with the 'use server' directive. Without it, the function runs on the client (or throws an error). The directive can be placed at the top of a file (all exported functions become Server Actions) or at the top of an individual function body.
A common mistake: placing 'use server' after imports or after the function declaration. The directive must be the FIRST statement of the file or the FIRST statement of the function body. Comments are allowed before it.
// ============================================ // Mistake 3: Missing or Misplaced 'use server' Directive // ============================================ // ---- WRONG: 'use server' not the first statement ---- // This file has a comment before the directive β it is ignored // File: app/actions.ts // This is my server actions file 'use server' // ERROR: not the first statement β directive is ignored export async function createPost(title: string) { // This runs on the client, not the server // Database calls fail because the client cannot access the DB } // ---- CORRECT: 'use server' as the first statement (comments allowed) ---- // File: app/actions.ts // Comments are allowed before the directive 'use server' import { prisma } from '@/lib/db' export async function createPost(title: string) { // This runs on the server β DB access works return prisma.post.create({ data: { title } }) } // ---- WRONG: 'use server' after function ---- export async function deletePost(id: string) { 'use server' // ERROR: must be the FIRST statement of the function body return prisma.post.delete({ where: { id } }) } // ---- CORRECT: 'use server' as first statement of function body ---- export async function deletePost(id: string) { 'use server' const prisma = (await import('@/lib/db')).prisma return prisma.post.delete({ where: { id } }) } // ---- WRONG: Passing non-async function as Server Action ---- // Server Actions must be async functions 'use server' export function createPostSync(title: string) { // ERROR: Server Actions must be async return prisma.post.create({ data: { title } }) } // ---- CORRECT: Async function ---- 'use server' export async function createPostAsync(title: string) { return prisma.post.create({ data: { title } }) } // ---- WRONG: Server Action with non-serializable parameters ---- // FormData is serializable β but Map, Set, class instances are not 'use server' export async function processData(data: Map<string, string>) { // ERROR: Map is not serializable from FormData } // ---- CORRECT: Accept FormData or serializable types ---- 'use server' export async function processData(formData: FormData) { const title = formData.get('title') as string const content = formData.get('content') as string // Process serializable data } // ---- Server Action with return value ---- // Return values must also be serializable 'use server' export async function getPost(id: string) { const post = await prisma.post.findUnique({ where: { id } }) return post // Plain object β serializable // Do NOT return functions, class instances, or React elements }
Mistake 4: Using useRouter from next/router in App Router
The Pages Router uses 'next/router' for navigation. The App Router uses 'next/navigation'. These are different APIs with different methods. Importing from 'next/router' in an App Router project either throws an error or returns undefined methods.
The key difference: the Pages Router useRouter has isReady, asPath, and query. The App Router usePathname, useSearchParams, and useRouter from 'next/navigation' have a different API. useRouter in App Router only has push, replace, refresh, back, forward, and prefetch β no isReady, no asPath, no query.
This mistake is common when migrating from Pages Router to App Router or when copying code from older tutorials.
// ============================================ // Mistake 4: Wrong Router Import in App Router // ============================================ // ---- WRONG: Pages Router import in App Router ---- // This import path does not exist in App Router context 'use client' import { useRouter } from 'next/router' // ERROR: Pages Router only export function LoginForm() { const router = useRouter() async function handleLogin() { await login() router.push('/dashboard') // This may throw or be undefined } return <button onClick={handleLogin}>Login</button> } // ---- CORRECT: App Router imports ---- 'use client' import { useRouter, usePathname, useSearchParams } from 'next/navigation' export function LoginForm() { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() async function handleLogin() { await login() router.push('/dashboard') } return <button onClick={handleLogin}>Login</button> } // ---- API Differences ---- // Pages Router useRouter (next/router): // - router.push(url) β navigate // - router.replace(url) β navigate without history // - router.back() β go back // - router.query β URL query params (object) // - router.asPath β current path with query string // - router.isReady β true when router is initialized // - router.pathname β current path without query // App Router useRouter (next/navigation): // - router.push(url) β navigate // - router.replace(url) β navigate without history // - router.refresh() β refresh current page (no full reload) // - router.back() β go back // - router.forward() β go forward // - router.prefetch(url) β prefetch a route // - NO query, NO asPath, NO isReady // ---- Getting query params in App Router ---- // Pages Router: const { id } = useRouter().query // App Router: use useSearchParams() hook 'use client' import { useSearchParams } from 'next/navigation' export function ProductPage() { const searchParams = useSearchParams() const id = searchParams.get('id') // Get single param const sort = searchParams.get('sort') // Get single param // Get all params const allParams = Object.fromEntries(searchParams.entries()) return <div>Product ID: {id}</div> } // ---- Getting path params in App Router ---- // Pages Router: useRouter().query (mixed with query params) // App Router: useParams() hook or page props 'use client' import { useParams } from 'next/navigation' export function ProductPage() { const params = useParams() // params.id comes from app/products/[id]/page.tsx return <div>Product ID: {params.id}</div> } // ---- Or use page props directly (Server Component) ---- // app/products/[id]/page.tsx export default async function ProductPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params return <div>Product ID: {id}</div> }
Mistake 5: useEffect in Layouts Does Not Fire on Navigation
Layouts in App Router persist across navigations. When a user navigates from /dashboard to /settings, the dashboard layout stays mounted β only the page content changes. This means useEffect in a layout fires once on initial load and never again on subsequent navigations.
This breaks patterns that rely on useEffect to track page views, reset form state, or fetch data on route change. Developers expect the layout to re-mount on navigation β it does not.
// ============================================ // Mistake 5: useEffect in Layouts Fires Only Once // ============================================ // ---- WRONG: Page view tracking in layout ---- // This fires only on the FIRST page load β not on navigation // app/(dashboard)/layout.tsx 'use client' import { useEffect } from 'react' import { usePathname } from 'next/navigation' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const pathname = usePathname() useEffect(() => { // This fires only on initial mount β NOT on navigation // Page views are not tracked after the first page analytics.track('page_view', { path: pathname }) }, []) // Empty dependency array β fires once return <div>{children}</div> } // ---- CORRECT: Track page views with pathname dependency ---- // The layout persists, but pathname changes β use it as dependency // app/(dashboard)/layout.tsx 'use client' import { useEffect } from 'react' import { usePathname } from 'next/navigation' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const pathname = usePathname() useEffect(() => { // This fires on EVERY pathname change analytics.track('page_view', { path: pathname }) }, [pathname]) // pathname is the dependency return <div>{children}</div> } // ---- WRONG: Resetting form state in layout ---- // Layout persists β form state is not reset on navigation // app/(dashboard)/layout.tsx 'use client' import { useState } from 'react' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const [formData, setFormData] = useState({ name: '', email: '' }) // This state persists across navigations // User navigates away and back β form data is still filled return <div>{children}</div> } // ---- CORRECT: Use key prop or reset in page component ---- // Reset form state in the page component, not the layout // app/(dashboard)/settings/page.tsx 'use client' import { useState, useEffect } from 'react' import { usePathname } from 'next/navigation' export default function SettingsPage() { const [formData, setFormData] = useState({ name: '', email: '' }) // Reset form when the page mounts // Pages DO re-mount on navigation (unlike layouts) useEffect(() => { setFormData({ name: '', email: '' }) }, []) return ( <form> <input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} /> </form> ) } // ---- Alternative: Use key prop to force re-mount ---- // app/(dashboard)/layout.tsx 'use client' import { usePathname } from 'next/navigation' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const pathname = usePathname() return ( <div> {/* key forces re-mount when pathname changes */} <div key={pathname}>{children}</div> </div> ) }
- Layouts persist across navigations β they do not re-mount or re-run effects
- Pages re-mount on navigation β useEffect in pages fires on every navigation
- For page view tracking in layouts, use pathname as the useEffect dependency β not an empty array
- For form state reset, handle it in the page component β not the layout
- Use key={pathname} on the children wrapper to force re-mount if needed
Mistake 6: Accessing Browser APIs in Server Components
Server Components render on the server β they have no access to browser APIs like window, document, localStorage, sessionStorage, navigator, or location. Accessing any of these in a Server Component throws a ReferenceError during server-side rendering.
The error is obvious for direct access (window is not defined). The subtle version: importing a library that accesses browser APIs internally (like a date picker that checks window.innerWidth during import). The error occurs at import time, not at render time, which makes it harder to trace.
// ============================================ // Mistake 6: Browser APIs in Server Components // ============================================ // ---- WRONG: Direct window access in Server Component ---- // This throws: ReferenceError: window is not defined // app/page.tsx (Server Component β no 'use client') export default function HomePage() { const width = window.innerWidth // ERROR: window is not defined on server return <div>Window width: {width}</div> } // ---- WRONG: localStorage in Server Component ---- export default function HomePage() { const theme = localStorage.getItem('theme') // ERROR return <div>Theme: {theme}</div> } // ---- WRONG: document in Server Component ---- export default function HomePage() { const title = document.title // ERROR return <div>Title: {title}</div> } // ---- WRONG: Library that accesses browser APIs at import time ---- // Some libraries check window during module initialization // app/page.tsx import { SomeChartLibrary } from 'react-chart-lib' // ERROR if library accesses window export default function ChartPage() { return <SomeChartLibrary data={data} /> } // ---- CORRECT: Move to Client Component ---- // components/window-width.tsx 'use client' import { useState, useEffect } from 'react' export function WindowWidth() { const [width, setWidth] = useState(0) useEffect(() => { // useEffect runs only on the client β window is available setWidth(window.innerWidth) const handleResize = () => setWidth(window.innerWidth) window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) return <div>Window width: {width}</div> } // app/page.tsx (Server Component) import { WindowWidth } from '@/components/window-width' export default function HomePage() { return ( <div> <h1>Home</h1> <WindowWidth /> {/* Client Component β accesses window safely */} </div> ) } // ---- CORRECT: Dynamic import with ssr: false ---- // For libraries that must access browser APIs at import time // app/page.tsx import dynamic from 'next/dynamic' const SomeChartLibrary = dynamic( () => import('react-chart-lib').then((mod) => mod.SomeChartLibrary), { ssr: false, loading: () => <div>Loading chart...</div> } ) export default function ChartPage() { return <SomeChartLibrary data={data} /> } // ---- CORRECT: Conditional access with typeof check ---- // For utility functions that may run on server or client function getTheme(): string { if (typeof window === 'undefined') { return 'light' // Server default } return localStorage.getItem('theme') ?? 'light' } // ---- CORRECT: Read cookies/headers in Server Component instead ---- // Server Components CAN access cookies and headers import { cookies, headers } from 'next/headers' export default async function HomePage() { const cookieStore = await cookies() const theme = cookieStore.get('theme')?.value ?? 'light' const headersList = await headers() const userAgent = headersList.get('user-agent') return <div>Theme: {theme}, UA: {userAgent}</div> }
Mistake 7: Incorrect next/image Usage
The next/image component requires either explicit width and height props (for fixed-size images) or the fill prop (for responsive images). Without either, the image renders with zero dimensions or throws an error. Additionally, external images require domain configuration in next.config.
Common mistakes: forgetting to set width/height, using fill without position: relative on the parent, not configuring remotePatterns for external image domains, and using regular img tags instead of next/image (losing optimization).
// ============================================ // Mistake 7: Incorrect next/image Usage // ============================================ // ---- WRONG: No width/height and no fill ---- // Image renders with zero dimensions import Image from 'next/image' export function ProductCard() { return ( <Image src="/product.jpg" alt="Product" // ERROR: missing width, height, or fill /> ) } // ---- CORRECT: Fixed size with width and height ---- export function ProductCard() { return ( <Image src="/product.jpg" alt="Product" width={400} height={300} className="rounded-lg" /> ) } // ---- CORRECT: Responsive with fill ---- // Parent must have position: relative export function ProductCard() { return ( <div className="relative h-64 w-full"> <Image src="/product.jpg" alt="Product" fill className="object-cover rounded-lg" /> </div> ) } // ---- WRONG: External image without domain config ---- // This throws: Invalid src prop on next/image export function Avatar() { return ( <Image src="https://cdn.example.com/avatar.jpg" alt="Avatar" width={100} height={100} // ERROR: cdn.example.com is not configured /> ) } // ---- CORRECT: Configure remotePatterns in next.config ---- // next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com', pathname: '/**', }, { protocol: 'https', hostname: '*.supabase.co', pathname: '/storage/v1/object/public/**', }, ], }, } export default nextConfig // ---- WRONG: Using regular <img> tag ---- // Loses: automatic optimization, lazy loading, responsive sizing, // WebP/AVIF conversion, blur placeholder export function Hero() { return <img src="/hero.jpg" alt="Hero" /> // Works but unoptimized } // ---- CORRECT: Use next/image ---- export function Hero() { return ( <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority // Load immediately β skip lazy loading placeholder="blur" // Show blur placeholder while loading blurDataURL="data:image/jpeg;base64,..." // Base64 blur image /> ) } // ---- Using sizes prop for responsive images ---- // Without sizes, the browser downloads the largest variant export function ProductGrid() { return ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> {products.map((product) => ( <Image key={product.id} src={product.image} alt={product.name} fill sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="object-cover" /> ))} </div> ) }
- Always provide width/height (fixed) or fill (responsive) β without either, the image has zero dimensions
- External images require remotePatterns configuration in next.config β wildcards are supported
- Use fill with position: relative on the parent β otherwise the image overflows
- Add sizes prop for responsive images β without it, the browser downloads the largest variant
- Use priority for above-the-fold images β it skips lazy loading and preloads the image
Mistake 8: Misconfigured Middleware Matcher
Middleware runs on every request by default β including static assets like images, fonts, and JavaScript bundles. This adds unnecessary latency because each static asset request triggers the middleware function, which may include auth checks, redirects, or other logic.
The matcher configuration in middleware.ts controls which routes the middleware runs on. Without a matcher, it runs on everything. With an incorrect matcher, it may skip routes that need protection or include routes that should be excluded.
// ============================================ // Mistake 8: Middleware Runs on Static Assets // ============================================ // ---- WRONG: No matcher β middleware runs on EVERYTHING ---- // File: middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { // This runs on: // /dashboard (intended) // /api/users (intended) // /_next/static/chunks/main.js (unintended β adds latency) // /_next/image?url=... (unintended β adds latency) // /favicon.ico (unintended) // /images/logo.png (unintended) // Each static asset request adds 5-50ms of middleware overhead // A page with 20 static assets adds 100ms-1s of total latency return NextResponse.next() } // ---- CORRECT: Matcher excludes static assets and API routes (if they handle their own auth) ---- export function middleware(request: NextRequest) { // This runs only on matched routes return NextResponse.next() } export const config = { matcher: [ /* * Match all request paths except: * - api (API routes handle their own auth) * - _next/static (static files) * - _next/image (image optimization) * - favicon.ico (favicon) * - Static file extensions (images, fonts, etc.) */ '/((?!api|_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } // ---- Matcher for specific routes only ---- // If middleware only needs to protect certain routes export const configSpecific = { matcher: ['/dashboard/:path*', '/settings/:path*', '/admin/:path*'], } // ---- Performance impact measurement ---- // Without matcher: // - 50 static assets per page = 50 middleware invocations // - Average middleware latency: 10ms // - Total added latency: 500ms // // With matcher: // - 1 page request = 1 middleware invocation // - Total added latency: 10ms // - Improvement: 490ms (98% reduction)
Mistake 9: Not Understanding Streaming and Suspense Boundaries
Next.js 16 uses React Suspense for streaming β pages render progressively, with loading states shown while async data loads. Without Suspense boundaries, the entire page blocks on the slowest data fetch. With too many boundaries, the page shows excessive loading spinners.
The key insight: Suspense boundaries control what shows a loading state while data loads. Place them around components that fetch data independently. Do not wrap the entire page in a single boundary β that defeats the purpose of streaming.
This is also how Partial Prerendering (PPR) works in Next.js 16 β the static shell streams first, and Suspense holes fill in dynamically.
// ============================================ // Mistake 9: Misconfigured Suspense Boundaries // ============================================ // ---- WRONG: No Suspense boundary ---- // The entire page blocks on the slowest fetch // app/dashboard/page.tsx export default async function DashboardPage() { // Both fetches must complete before ANY content is shown const [stats, activity, notifications] = await Promise.all([ getStats(), // 200ms getActivity(), // 500ms getNotifications() // 1500ms β slowest β blocks everything ]) return ( <div> <StatsPanel data={stats} /> <ActivityFeed data={activity} /> <Notifications data={notifications} /> </div> ) // User sees nothing for 1.5 seconds β then everything appears at once } // ---- CORRECT: Suspense boundaries for independent sections ---- // Each section loads independently β user sees content progressively // app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div> <Suspense fallback={<StatsPanelSkeleton />}> <StatsPanel /> {/* Fetches independently β 200ms */} </Suspense> <Suspense fallback={<ActivityFeedSkeleton />}> <ActivityFeed /> {/* Fetches independently β 500ms */} </Suspense> <Suspense fallback={<NotificationsSkeleton />}> <Notifications /> {/* Fetches independently β 1500ms */} </Suspense> </div> ) // Stats appear at 200ms, activity at 500ms, notifications at 1500ms } // components/stats-panel.tsx async function StatsPanel() { const stats = await getStats() // 200ms return <div>{/* render stats */}</div> } // ---- WRONG: Wrapping everything in one Suspense ---- // This is equivalent to having no Suspense β the slowest blocks all export default function DashboardPage() { return ( <Suspense fallback={<PageSkeleton />}> <StatsPanel /> <ActivityFeed /> <Notifications /> </Suspense> ) // Fallback shows until ALL three resolve β same as no Suspense } // ---- CORRECT: Nested Suspense for different loading priorities ---- // Critical content loads first, secondary content loads later export default function DashboardPage() { return ( <div> {/* Critical: always show immediately */} <Header /> {/* Primary content: show skeleton while loading */} <Suspense fallback={<StatsPanelSkeleton />}> <StatsPanel /> </Suspense> {/* Secondary content: nested Suspense for fine-grained control */} <div className="grid grid-cols-2 gap-4"> <Suspense fallback={<ActivityFeedSkeleton />}> <ActivityFeed /> </Suspense> <Suspense fallback={<NotificationsSkeleton />}> <Notifications /> </Suspense> </div> </div> ) } // ---- loading.tsx: Automatic Suspense for page segments ---- // app/dashboard/loading.tsx // This file creates an automatic Suspense boundary for the page export default function DashboardLoading() { return <div className="animate-pulse">Loading dashboard...</div> } // ---- error.tsx: Error boundary for page segments ---- // app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div> <h2>Something went wrong</h2> <button onClick={() => reset()}>Try again</button> </div> ) }
- No Suspense: the entire page blocks on the slowest fetch β user sees nothing until everything loads
- One Suspense wrapping everything: same problem β fallback shows until all children resolve
- Multiple Suspense boundaries: each section loads independently β user sees content progressively
- This is how Partial Prerendering (PPR) works in Next.js 16 β static shell streams first, Suspense holes fill in
- loading.tsx creates automatic Suspense boundaries for page segments
Mistake 10: params and searchParams Are Now Promises (Next.js 15+)
In Next.js 15 and 16, the params and searchParams props passed to page components and layout components are now Promises. This change was introduced to support async params in dynamic routes.
The old synchronous destructuring (const { id } = params) no longer works and will throw or return undefined. You must await the Promise first.
// ============================================ // Mistake 10: params and searchParams Are Now Promises // ============================================ // ---- WRONG: Synchronous destructuring (Next.js 14 and earlier) ---- // This breaks in Next.js 15/16 // app/products/[id]/page.tsx export default function ProductPage({ params, searchParams, }: { params: { id: string } searchParams: { tab?: string } }) { const id = params.id // ERROR: params is now a Promise const tab = searchParams.tab return <div>Product {id}</div> } // ---- CORRECT: Await the Promise (Next.js 15/16) ---- export default async function ProductPage({ params, searchParams, }: { params: Promise<{ id: string }> searchParams: Promise<{ tab?: string }> }) { const { id } = await params const { tab } = await searchParams return <div>Product {id} β Tab: {tab}</div> } // ---- WRONG: In layout.tsx (same rule applies) ---- // app/[slug]/layout.tsx export default function Layout({ params, }: { params: { slug: string } }) { const slug = params.slug // ERROR } // ---- CORRECT: Await in layout (layouts can be async) ---- export default async function Layout({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params return <div>Layout for {slug}</div> } // ---- Production impact ---- // Without await: params.id is undefined or the whole object is a Promise // Page renders with wrong data or crashes on .id access
Mistake 11: Missing Error Boundaries and error.tsx
Next.js App Router uses error.tsx files as error boundaries for route segments. Without them, an unhandled error in a Server Component crashes the entire application β the user sees a blank page or a raw error stack trace.
The error.tsx file must be a Client Component ('use client') because it receives error and reset props that are interactive. It catches errors in its own segment and all child segments β but not in the parent layout.
A common mistake: placing error.tsx only at the root level. This catches all errors but shows a generic error page for every failure. Place error.tsx at each route segment for granular error handling β a failed dashboard widget should not crash the entire application.
// ============================================ // Mistake 11: Missing or Misconfigured Error Boundaries // ============================================ // ---- WRONG: No error.tsx anywhere ---- // An unhandled error crashes the entire app // User sees: blank page, raw stack trace, or browser error // app/dashboard/page.tsx export default async function DashboardPage() { const stats = await getStats() // If this throws, the entire page crashes return <div>{stats.total}</div> } // ---- CORRECT: error.tsx at each route segment ---- // File: app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { // error.message β the error message // error.digest β unique error ID for logging // reset() β retry the render return ( <div className="flex flex-col items-center justify-center p-8"> <h2 className="text-xl font-bold">Dashboard Error</h2> <p className="text-muted-foreground mt-2"> Something went wrong loading the dashboard. </p> <button onClick={reset} className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground" > Try Again </button> </div> ) } // ---- WRONG: error.tsx without 'use client' ---- // error.tsx MUST be a Client Component // app/dashboard/error.tsx export default function DashboardError({ error, reset }) { // ERROR: error.tsx must have 'use client' directive // Server Components cannot receive interactive props like reset return <div>Error</div> } // ---- CORRECT: Granular error boundaries per segment ---- // app/layout.tsx β Root error boundary (catches unhandled errors) // app/error.tsx β catches errors in all child routes // app/dashboard/error.tsx β catches errors in dashboard and its children // app/dashboard/settings/error.tsx β catches errors in settings specifically // app/dashboard/analytics/error.tsx β catches errors in analytics specifically // ---- Global error boundary for root layout errors ---- // File: app/global-error.tsx // Catches errors in the root layout itself // Must include <html> and <body> tags 'use client' export default function GlobalError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <html> <body> <div className="flex min-h-screen items-center justify-center"> <div className="text-center"> <h2 className="text-2xl font-bold">Something went wrong</h2> <p className="text-muted-foreground mt-2"> An unexpected error occurred. Error ID: {error.digest} </p> <button onClick={reset} className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground" > Reload Application </button> </div> </div> </body> </html> ) } // ---- not-found.tsx: Handle 404s ---- // File: app/dashboard/not-found.tsx export default function DashboardNotFound() { return ( <div> <h2>Dashboard Not Found</h2> <p>The requested dashboard does not exist.</p> </div> ) } // Trigger a 404 from a Server Component: import { notFound } from 'next/navigation' export default async function DashboardPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const dashboard = await getDashboard(id) if (!dashboard) { notFound() // Renders the nearest not-found.tsx } return <div>{dashboard.name}</div> }
| Feature | Pages Router | App Router | Migration Action |
|---|---|---|---|
| Router | next/router | next/navigation | Change import path, use useSearchParams for query |
| Data Fetching | getServerSideProps, getStaticProps | async Server Components, fetch() | Remove get*Props, use fetch() with cache directives |
| API Routes | pages/api/*.ts | app/api/*/route.ts | Move to app/api, export named HTTP methods |
| Error Handling | _error.tsx, 500.tsx | error.tsx, global-error.tsx | Create error.tsx at each segment |
| Loading States | Custom implementation | loading.tsx, Suspense | Create loading.tsx or wrap in Suspense |
| 404 Pages | 404.tsx | not-found.tsx | Rename to not-found.tsx, call notFound() for dynamic 404s |
| Head/Metadata | next/head | metadata export, generateMetadata | Export metadata object or generateMetadata function |
| CSS Modules | Same | Same | No change needed |
| Image Optimization | next/image | next/image (same) | No change needed |
| Caching | fetch cached by default | fetch NOT cached by default | Add explicit cache directives to all fetch calls |
| Route Params | params is object | params is Promise | Await params and searchParams |
π― Key Takeaways
- fetch() stopped caching by default in Next.js 15 (still true in 16) β add explicit cache directives to every fetch call
- Server-Client props must be JSON-serializable β functions, Map, Set, class instances are not allowed (Server Actions are the exception)
- 'use server' must be the first statement β comments are allowed before it
- Import useRouter from 'next/navigation' β not 'next/router' which is Pages Router only
- Layouts persist across navigations β useEffect in layouts fires only on initial mount
- Server Components cannot access window, document, localStorage β use Client Components for browser APIs
- Middleware must have a matcher β without it, static assets add unnecessary latency
- params and searchParams are now Promises β always await them
- Place error.tsx at each route segment β not just at the root β for granular error handling
β Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat changed about fetch() caching in Next.js 15/16, and how do you handle it?Mid-levelReveal
- QWhat data can you pass from a Server Component to a Client Component, and what cannot?Mid-levelReveal
- QWhy does useEffect in a layout only fire once, and how do you handle page tracking?Mid-levelReveal
- QHow do Suspense boundaries affect page loading performance in Next.js 16?SeniorReveal
- QWhat is the difference between error.tsx, global-error.tsx, and not-found.tsx in App Router?SeniorReveal
Frequently Asked Questions
Can I still use the Pages Router in Next.js 16?
Yes, the Pages Router is still supported in Next.js 16. Both routers can coexist in the same project β Pages Router in the pages/ directory and App Router in the app/ directory. However, new features and optimizations are focused on the App Router. For new projects, the App Router is recommended.
How do I migrate from getServerSideProps to App Router data fetching?
Remove getServerSideProps and make the page component async. Fetch data directly inside the component using fetch() with { cache: 'no-store' } for dynamic data (equivalent to getServerSideProps). The component renders on the server with the fetched data. For static data, use { cache: 'force-cache' } (equivalent to getStaticProps).
What happens if I forget to add 'use server' to a Server Action?
The function runs on the client instead of the server. If it accesses a database or other server-only resource, it fails with a connection error or ReferenceError. If it only uses client-safe APIs, it runs without error but on the client β which may expose sensitive logic or cause unexpected behavior.
Can I use Suspense with Server Components?
Yes, Suspense works with Server Components. When you wrap an async Server Component in a Suspense boundary, Next.js streams the component's output. The fallback is shown immediately, and the actual content replaces it when the async component resolves. This is the primary mechanism for streaming in App Router.
How do I handle authentication errors in middleware?
In middleware, check the user session with supabase.auth.getUser() or your auth provider's equivalent. If the user is not authenticated and the route is protected, redirect to the login page. If the token is expired, the middleware should refresh it automatically. Return NextResponse.redirect(new URL('/login', request.url)) for unauthenticated users on protected routes.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.