Senior 5 min · April 12, 2026

Next.js 16 fetch() Caching — Why Your API Costs Tripled

Next.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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+
Plain-English First

Next.js 15 (and still true in 16) changed several defaults that developers relied on in earlier versions. Caching is no longer automatic. Server and Client Components have strict boundaries. The old Pages Router APIs do not work in App Router. These changes are not bugs — they are intentional design decisions. The mistakes happen when developers assume old patterns still work without checking the new defaults.

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.

io.thecodeforge.nextjs16.fetch-cache.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// ============================================
// 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
fetch() Default Changed in Next.js 15 (Still True in 16)
  • Next.js 13-14: fetch() cached by default — equivalent to { cache: 'force-cache' }
  • Next.js 15/16: fetch() does NOT cache by default — behaves like native fetch
  • Without a cache directive, every page load makes a fresh HTTP request
  • ISR revalidation is useless without fetch caching — the page revalidates but fetch returns fresh data every time
  • Audit all fetch calls after upgrading — grep for fetch() and verify each has a cache directive
Production Insight
fetch() without a cache directive makes a fresh HTTP request on every render.
ISR revalidation is ineffective without fetch caching — the page revalidates but fetch returns fresh data.
Rule: add explicit cache directive to every fetch call — audit after every Next.js upgrade.
Key Takeaway
fetch() stopped caching by default in Next.js 15 (still true in 16) — every call without a directive is a fresh request.
Use { cache: 'force-cache' }, { next: { revalidate: N } }, or { next: { tags: [...] } }.
Audit all fetch calls after upgrading — do not assume default behavior is unchanged.

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.

io.thecodeforge.nextjs16.serialization.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
// ============================================
// 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
The Serialization Boundary
  • 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
Production Insight
Only JSON-serializable data crosses the Server-Client boundary — functions, Maps, and class instances are lost.
Subtle cases fail silently: Date loses prototype methods, Map serializes to empty object.
Rule: pass primitive types and plain objects — reconstruct complex types on the client. Server Actions are the only functions you can safely pass.
Key Takeaway
Server-to-Client props must be JSON-serializable — functions, class instances, Map, Set are not allowed.
Server Actions (marked 'use server') are the exception and can be passed as props.
Use Server Actions for functions, ISO strings for Dates, plain objects for Maps.

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.

io.thecodeforge.nextjs16.server-actions.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// ============================================
// 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
}
'use server' Placement Rules
  • 'use server' must be the FIRST statement of the file (for all exports) or the FIRST statement of the function body
  • Comments are allowed before the directive — but no other code
  • Server Actions must be async functions — sync functions are not allowed
  • Parameters and return values must be serializable — FormData, strings, plain objects
  • If 'use server' is ignored, the function runs on the client — database calls fail silently
Production Insight
'use server' must be the first statement — comments are allowed before it.
Without 'use server', the function runs on the client — database calls fail with connection errors.
Rule: place 'use server' at the top of the file or as the first statement of the function body.
Key Takeaway
'use server' must be the first statement of the file or function body — comments are allowed before it.
Server Actions must be async — sync functions are not allowed.
Parameters and return values must be serializable — FormData, strings, plain objects only.

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.

io.thecodeforge.nextjs16.router-migration.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// ============================================
// 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>
}
Pages Router vs App Router APIs
  • Pages Router: import from 'next/router' — has query, asPath, isReady
  • App Router: import from 'next/navigation' — has push, replace, refresh, prefetch
  • App Router has NO query, NO asPath, NO isReady on useRouter
  • Use useSearchParams() for query params, useParams() for dynamic route params, usePathname() for the path
  • Mixing imports from the two routers causes runtime errors or undefined behavior
Production Insight
Importing from 'next/router' in App Router throws errors or returns undefined methods.
App Router useRouter has fewer methods — no query, no asPath, no isReady.
Rule: use 'next/navigation' in App Router — useSearchParams for query, useParams for route params.
Key Takeaway
Pages Router uses 'next/router' — App Router uses 'next/navigation' — they are different APIs.
App Router useRouter has push, replace, refresh, prefetch — no query or asPath.
Use useSearchParams() for query params, useParams() for dynamic route segments.

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.

io.thecodeforge.nextjs16.layout-effects.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// ============================================
// 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, Pages Re-Mount
  • 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
Production Insight
Layouts persist across navigations — useEffect in layouts fires only on initial mount.
Page view tracking with empty dependency array misses all navigations after the first.
Rule: use pathname as useEffect dependency in layouts, or move effects to page components.
Key Takeaway
Layouts persist across navigations — useEffect with empty deps fires only once.
Use pathname as the dependency to track navigation changes in layouts.
Pages re-mount on navigation — move state-reset logic to page components.

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.

io.thecodeforge.nextjs16.browser-apis.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// ============================================
// 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>
}
Server Components Have No Browser Context
  • window, document, localStorage, sessionStorage, navigator — none exist on the server
  • Direct access throws ReferenceError: window is not defined
  • Libraries that check window during import also fail — even if you do not call the checking code
  • Use 'use client' for components that need browser APIs — or dynamic import with ssr: false
  • Server Components CAN access cookies() and headers() from next/headers — use these instead of browser storage
Production Insight
Server Components have no browser context — window, document, localStorage all throw ReferenceError.
Libraries that access window during import fail at module load time — not at render time.
Rule: use 'use client' for browser APIs, dynamic import with ssr: false for problematic libraries.
Key Takeaway
Server Components cannot access window, document, localStorage, or any browser API.
Move browser-dependent code to Client Components or use dynamic import with ssr: false.
Server Components CAN access cookies() and headers() — use these for server-side state.

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).

io.thecodeforge.nextjs16.image-usage.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// ============================================
// 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>
  )
}
next/image Best Practices
  • 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
Production Insight
next/image without width/height or fill renders with zero dimensions — the image is invisible.
External images require remotePatterns in next.config — unconfigured domains throw errors.
Rule: always set width/height or fill, configure remotePatterns for external images, add sizes for responsive.
Key Takeaway
next/image requires width/height or fill — without either, the image has zero dimensions.
External images need remotePatterns in next.config — wildcards are supported for subdomains.
Use sizes prop for responsive images — without it, the browser downloads unnecessarily large variants.

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.

io.thecodeforge.nextjs16.middleware.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ============================================
// 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)
Middleware Matcher Performance Impact
  • Without a matcher, middleware runs on every request — including static assets
  • Each static asset invocation adds 5-50ms of latency — a page with 20 assets adds 100ms-1s
  • Exclude _next/static, _next/image, favicon.ico, and static file extensions
  • Exclude /api routes if they handle their own auth — middleware adds redundant checks
  • A correct matcher reduces middleware invocations by 95%+ on typical pages
Production Insight
Middleware without a matcher runs on every request — including static assets, adding 5-50ms each.
A page with 20 static assets adds 100ms-1s of total latency from middleware alone.
Rule: always configure matcher to exclude static assets and API routes that handle their own auth.
Key Takeaway
Middleware runs on every request by default — static assets add unnecessary latency.
Configure matcher to exclude _next/static, _next/image, favicon.ico, and file extensions.
A correct matcher reduces middleware invocations by 95%+ on typical pages.

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.

io.thecodeforge.nextjs16.streaming.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
// ============================================
// 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>
  )
}
Suspense Controls Progressive Loading
  • 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
Production Insight
Without Suspense boundaries, the entire page blocks on the slowest data fetch.
Multiple boundaries enable progressive loading — each section appears as its data resolves.
Rule: place Suspense around independent data fetches — not around the entire page.
Key Takeaway
Suspense boundaries control progressive loading — place them around independent data fetches.
One boundary wrapping everything defeats streaming — it blocks on the slowest child.
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.

io.thecodeforge.nextjs16.params-promise.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// ============================================
// 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
params/searchParams Are Promises in Next.js 15/16
  • params and searchParams are now async Promises to support dynamic async params
  • You MUST await them: const { id } = await params
  • This applies to every page.tsx, layout.tsx, and route handlers
  • Forgetting to await is the #1 upgrade pain point from Next.js 14 → 15/16
  • Server Components can be async — just mark the function async
Production Insight
params and searchParams became Promises in Next.js 15 (still true in 16).
Synchronous destructuring now returns undefined or the raw Promise object.
Rule: always await params and searchParams in page and layout components.
Key Takeaway
params and searchParams are now Promises in Next.js 15/16.
Always await: const { id } = await params.
This is the most common upgrade gotcha from Next.js 14.

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.

io.thecodeforge.nextjs16.error-handling.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// ============================================
// 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>
}
Error Boundaries Are Not Optional
  • Without error.tsx, unhandled errors crash the page — user sees blank screen or raw stack trace
  • error.tsx must have 'use client' — it receives interactive props (reset) that require client-side rendering
  • Place error.tsx at each route segment for granular error handling — not just at the root
  • global-error.tsx catches errors in the root layout — must include <html> and <body> tags
  • not-found.tsx handles 404s — call notFound() from Server Components to trigger it
Production Insight
Without error.tsx, unhandled errors crash the page — user sees a blank screen or raw stack trace.
error.tsx must be a Client Component — it receives interactive props that need client rendering.
Rule: place error.tsx at each route segment — not just at the root — for granular error handling.
Key Takeaway
error.tsx is the error boundary for route segments — without it, errors crash the page.
error.tsx must be a Client Component with 'use client' — it receives error and reset props.
Place error.tsx at each segment for granular handling — global-error.tsx for root layout errors.
● Production incidentPOST-MORTEMseverity: high

fetch() Caching Change Caused 3x API Costs and +400ms Latency After Upgrade

Symptom
API costs tripled overnight. p95 page latency jumped +400ms. Product prices on the listing page became stale (showing prices from 6 hours ago). The CMS team confirmed prices were updated 20 minutes ago. The API endpoint returned the correct prices when called directly. Only the Next.js pages showed outdated data. The issue affected 40 product listing pages.
Assumption
fetch() responses were still cached by default (like in Next.js 14) and revalidated by the ISR revalidate setting on the page.
Root cause
Next.js 15 changed the default fetch() caching behavior. In Next.js 14, fetch() cached responses 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 request on every call. The team's pages used ISR with revalidate: 3600 (1 hour), but the underlying fetch calls were no longer cached. To "fix" the sudden performance regression they added an in-memory cache with a 6-hour TTL. This cache was never invalidated because the fetch bypassed the Next.js cache layer entirely.
Fix
Added explicit cache directives to all fetch calls: fetch(url, { next: { revalidate: 3600 } }) for time-based revalidation, or fetch(url, { cache: 'force-cache' }) for permanent caching. Removed the redundant in-memory cache utility that was masking the issue. Added a cache audit step to the migration checklist — grep for all fetch calls and verify each has an explicit cache directive. Added monitoring for cache hit rates to detect future regressions.
Key lesson
  • fetch() stopped caching by default in Next.js 15 (still true in 16) — every fetch call without a cache directive makes a fresh HTTP request.
  • Always add explicit cache directives: { cache: 'force-cache' } or { next: { revalidate: seconds } }.
  • Redundant caching layers (in-memory caches on top of Next.js cache) mask issues and create stale data bugs.
  • After a major version upgrade, audit all fetch calls — do not assume default behavior is unchanged.
Production debug guideDiagnose caching, serialization, and routing issues8 entries
Symptom · 01
Page shows stale data despite CMS update
Fix
Check fetch calls for explicit cache directives — Next.js 15/16 does not cache fetch by default
Symptom · 02
Error: Functions cannot be passed from Server to Client Component
Fix
Remove function props from Server-to-Client boundary — pass serializable data and handle logic in the Client Component (Server Actions are the exception)
Symptom · 03
Server Action returns 500 or does not execute
Fix
Verify 'use server' directive is the first statement in the file or at the top of the function body (comments are allowed)
Symptom · 04
useRouter.push() is undefined or throws
Fix
Import useRouter from 'next/navigation' — not 'next/router' which is Pages Router only
Symptom · 05
useEffect in layout does not fire on navigation
Fix
Layouts persist across navigations — move useEffect to the page component or use a key prop
Symptom · 06
Hydration mismatch error on page load
Fix
Check for Client Components rendering different content on server vs client — browser APIs, Date.now(), Math.random()
Symptom · 07
next/image shows broken image or wrong dimensions
Fix
Verify width/height or fill prop is set, and images.domains or images.remotePatterns is configured in next.config
Symptom · 08
Middleware runs on static assets causing latency
Fix
Update the matcher to exclude _next/static, _next/image, favicon.ico, and static file extensions
★ Next.js 16 Quick Debug ReferenceFast commands for diagnosing common Next.js 16 issues
Stale data on pages
Immediate action
Find all fetch calls missing cache directives
Commands
grep -rn 'fetch(' app/ lib/ --include='*.ts' --include='*.tsx' | grep -v 'revalidate\|cache' | head -20
curl -s -o /dev/null -w '%{http_code} %{time_total}s' https://your-api.com/data
Fix now
Add { next: { revalidate: 3600 } } or { cache: 'force-cache' } to every fetch call
Serialization error in Server Component+
Immediate action
Find props passed from Server to Client Components
Commands
grep -rn 'import.*from.*client\|"use client"' app/ --include='*.tsx' | head -20
grep -rn 'function\|=>\|Date.now\|Math.random|new Date' app/ --include='*.tsx' | grep -v 'use client' | head -20
Fix now
Pass only JSON-serializable data (strings, numbers, booleans, plain objects, arrays) across the Server-Client boundary
useRouter not found or undefined+
Immediate action
Check the import path
Commands
grep -rn "from 'next/router'" app/ --include='*.tsx' --include='*.ts' | head -10
grep -rn "from 'next/navigation'" app/ --include='*.tsx' --include='*.ts' | head -10
Fix now
Change import from 'next/router' to 'next/navigation' — Pages Router API is not available in App Router
Image optimization failing+
Immediate action
Check next.config for images configuration
Commands
cat next.config.ts | grep -A 10 'images'
ls -la public/images/ | head -10
Fix now
Add images.remotePatterns in next.config.ts for external images, or use width/height props for local images
Pages Router vs App Router: Key Differences
FeaturePages RouterApp RouterMigration Action
Routernext/routernext/navigationChange import path, use useSearchParams for query
Data FetchinggetServerSideProps, getStaticPropsasync Server Components, fetch()Remove get*Props, use fetch() with cache directives
API Routespages/api/*.tsapp/api/*/route.tsMove to app/api, export named HTTP methods
Error Handling_error.tsx, 500.tsxerror.tsx, global-error.tsxCreate error.tsx at each segment
Loading StatesCustom implementationloading.tsx, SuspenseCreate loading.tsx or wrap in Suspense
404 Pages404.tsxnot-found.tsxRename to not-found.tsx, call notFound() for dynamic 404s
Head/Metadatanext/headmetadata export, generateMetadataExport metadata object or generateMetadata function
CSS ModulesSameSameNo change needed
Image Optimizationnext/imagenext/image (same)No change needed
Cachingfetch cached by defaultfetch NOT cached by defaultAdd explicit cache directives to all fetch calls
Route Paramsparams is objectparams is PromiseAwait params and searchParams

Key takeaways

1
fetch() stopped caching by default in Next.js 15 (still true in 16)
add explicit cache directives to every fetch call
2
Server-Client props must be JSON-serializable
functions, Map, Set, class instances are not allowed (Server Actions are the exception)
3
'use server' must be the first statement
comments are allowed before it
4
Import useRouter from 'next/navigation'
not 'next/router' which is Pages Router only
5
Layouts persist across navigations
useEffect in layouts fires only on initial mount
6
Server Components cannot access window, document, localStorage
use Client Components for browser APIs
7
Middleware must have a matcher
without it, static assets add unnecessary latency
8
params and searchParams are now Promises
always await them
9
Place error.tsx at each route segment
not just at the root — for granular error handling

Common mistakes to avoid

11 patterns
×

fetch() without explicit cache directive

Symptom
Pages make fresh API calls on every render. API rate limits are hit faster. Page load time increases from network latency on every request. ISR revalidation is effectively useless.
Fix
Add cache directive to every fetch call: { cache: 'force-cache' } for static data, { next: { revalidate: seconds } } for time-based revalidation, or { next: { tags: ['tag'] } } for on-demand revalidation.
×

Passing functions from Server to Client Components

Symptom
Error: 'Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with use server'. The component fails to render.
Fix
Define the function inside the Client Component or use a Server Action (marked with 'use server'). Pass only serializable data (strings, numbers, plain objects) across the Server-Client boundary. Server Actions are serializable references and CAN be passed.
×

Importing useRouter from 'next/router' in App Router

Symptom
useRouter is undefined or methods like router.query and router.asPath throw errors. Navigation fails silently or with cryptic errors.
Fix
Import useRouter from 'next/navigation'. Use useSearchParams() for query params, useParams() for route params, and usePathname() for the current path.
×

useEffect in layout with empty dependency array for page tracking

Symptom
Page views are tracked only on the first page load. All subsequent navigations are not tracked because the layout persists and does not re-mount.
Fix
Use pathname from usePathname() as the useEffect dependency. The layout persists but pathname changes on navigation, triggering the effect.
×

Accessing window or localStorage in Server Components

Symptom
ReferenceError: window is not defined (or document, localStorage, navigator). The page fails during server-side rendering.
Fix
Move browser-dependent code to a Client Component (with 'use client'). Or use dynamic import with { ssr: false } for libraries that access browser APIs at import time.
×

next/image without width/height or fill prop

Symptom
Image renders with zero dimensions — invisible on the page. Or error: 'Image with src "..." must use width and height or fill prop'.
Fix
Provide width and height for fixed-size images, or use fill with position: relative on the parent container for responsive images.
×

Middleware without matcher configuration

Symptom
Middleware runs on every request including static assets. A page with 20 static assets adds 100ms-1s of total latency from middleware invocations.
Fix
Configure matcher to exclude _next/static, _next/image, favicon.ico, and static file extensions. This reduces middleware invocations by 95%+.
×

Missing error.tsx in route segments

Symptom
Unhandled errors crash the page — user sees blank screen, raw stack trace, or browser error. No graceful fallback or retry mechanism.
Fix
Create error.tsx at each route segment. It must be a Client Component with 'use client'. Create global-error.tsx at the root for root layout errors.
×

No Suspense boundaries — entire page blocks on slowest fetch

Symptom
User sees nothing until the slowest data fetch completes. A page with a 1.5-second fetch shows a blank screen for 1.5 seconds before everything appears at once.
Fix
Wrap independent data fetches in separate Suspense boundaries with skeleton fallbacks. Each section loads progressively — user sees content as it becomes available.
×

Placing 'use server' after imports or comments

Symptom
Server Action runs on the client instead of the server. Database calls fail because the client cannot access the database. The directive is silently ignored.
Fix
'use server' must be the FIRST statement of the file (for all exports) or the FIRST statement of the function body. Comments are allowed before it.
×

params and searchParams treated as synchronous objects

Symptom
params.id is undefined or the whole object is a Promise. Page renders with wrong data or crashes on .id access.
Fix
Await the Promise: const { id } = await params. This applies to every page.tsx, layout.tsx, and route handler in Next.js 15+.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What changed about fetch() caching in Next.js 15/16, and how do you hand...
Q02SENIOR
What data can you pass from a Server Component to a Client Component, an...
Q03SENIOR
Why does useEffect in a layout only fire once, and how do you handle pag...
Q04SENIOR
How do Suspense boundaries affect page loading performance in Next.js 16...
Q05SENIOR
What is the difference between error.tsx, global-error.tsx, and not-foun...
Q01 of 05SENIOR

What changed about fetch() caching in Next.js 15/16, and how do you handle it?

ANSWER
fetch() stopped caching by default in Next.js 15 (and is still true in 16). In Next.js 13-14, fetch() cached responses 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 request on every call. To handle this, add explicit cache directives to every fetch call: - { cache: 'force-cache' } for permanent caching (static data) - { next: { revalidate: 3600 } } for time-based revalidation (data that changes periodically) - { next: { tags: ['products'] } } for on-demand revalidation with revalidateTag() - { cache: 'no-store' } for always-fresh data (user-specific or real-time) Without explicit directives, every page load triggers a fresh API call — ISR revalidation becomes ineffective, API rate limits are hit faster, and page load time increases.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I still use the Pages Router in Next.js 16?
02
How do I migrate from getServerSideProps to App Router data fetching?
03
What happens if I forget to add 'use server' to a Server Action?
04
Can I use Suspense with Server Components?
05
How do I handle authentication errors in middleware?
🔥

That's React.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
Building Type-Safe Forms with Zod, React Hook Form & Next.js 16
25 / 47 · React.js
Next
Supabase Auth with Next.js 16 — The Complete 2026 Guide