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 NOTasyncfunctiongetProducts() {
const res = await fetch('https://api.example.com/products')return res.json()
}
// ---- CORRECT: Explicit cache directive ----// Option A: Cache permanently (static data)asyncfunctiongetProductsCached() {
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache',
})
return res.json()
}
// Option B: Time-based revalidation (revalidate every hour)asyncfunctiongetProductsRevalidated() {
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)asyncfunctiongetProductsTagged() {
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)asyncfunctiongetUserProfile() {
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
// ============================================
// Mistake2: Non-SerializablePropsAcrossServer-ClientBoundary
// ============================================
// ---- WRONG: Passing a function from Server to Client ----
// Thisthrows: "Functions cannot be passed to Client Components"
// app/page.tsx (ServerComponent)
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 ClientComponent
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 ServerAction (the loophole) ----
// ServerActions are serializable references and CAN be passed
// app/actions.ts
'use server'
export async function addToCart(productId: string) {
// ServerAction — runs on the server
// Can be called from ClientComponents
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: PassingDate object ----
// Date serializes to ISO string — loses prototype methods
// ServerComponent
export default async function Page() {
const event = await getEvent()
return <EventCard date={event.date} /> // event.date is a Date object
}
// ClientComponent'use client'
export function EventCard({ date }: { date: Date }) {
// date.toRelative() may not work — prototype methods lost
// date instanceofDate is false after serialization
return <p>{date.toLocaleDateString()}</p>
}
// ---- CORRECT: PassISO string, reconstruct on client ----
// ServerComponent
export default async function Page() {
const event = await getEvent()
return <EventCard date={event.date.toISOString()} />
}
// ClientComponent'use client'
export function EventCard({ date }: { date: string }) {
const dateObj = newDate(date)
return <p>{dateObj.toLocaleDateString()}</p>
}
// ---- WRONG: PassingMap or Set ----
// Map serializes to {} — all data is lost
// ServerComponent
export default async function Page() {
const map = newMap([['key', 'value']])
return <Display data={map} /> // ERROR: Map serializes to {}
}
// ---- CORRECT: Convert to plain object ----
// ServerComponent
export default async function Page() {
const map = newMap([['key', 'value']])
const plain = Object.fromEntries(map)
return <Display data={plain} />
}
// ---- WRONG: Passingclass instance ----
// Class instances lose their methods after serialization
classCalculator {
add(a: number, b: number) { return a + b }
}
// ServerComponent
export default async function Page() {
const calc = newCalculator()
return <CalcDisplay calc={calc} /> // ERROR: methods are lost
}
// ---- CORRECT: Pass data, not instances ----
// Move the logic to the ClientComponent or use a ServerAction
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.
// ============================================// 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 ignoredexportasyncfunctioncreatePost(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'exportasyncfunctioncreatePost(title: string) {
// This runs on the server — DB access worksreturn prisma.post.create({ data: { title } })
}
// ---- WRONG: 'use server' after function ----exportasyncfunctiondeletePost(id: string) {
'use server' // ERROR: must be the FIRST statement of the function bodyreturn prisma.post.delete({ where: { id } })
}
// ---- CORRECT: 'use server' as first statement of function body ----exportasyncfunctiondeletePost(id: string) {
'use server'const prisma = (awaitimport('@/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'exportfunctioncreatePostSync(title: string) {
// ERROR: Server Actions must be asyncreturn prisma.post.create({ data: { title } })
}
// ---- CORRECT: Async function ----'use server'exportasyncfunctioncreatePostAsync(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'exportasyncfunctionprocessData(data: Map<string, string>) {
// ERROR: Map is not serializable from FormData
}
// ---- CORRECT: Accept FormData or serializable types ----'use server'exportasyncfunctionprocessData(formData: FormData) {
const title = formData.get('title') asstringconst content = formData.get('content') asstring// Process serializable data
}
// ---- Server Action with return value ----// Return values must also be serializable'use server'exportasyncfunctiongetPost(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
// ============================================
// Mistake4: WrongRouterImport in AppRouter
// ============================================
// ---- WRONG: PagesRouterimport in AppRouter ----
// Thisimport path does not exist in AppRouter context
'use client'import { useRouter } from 'next/router' // ERROR: PagesRouter 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: AppRouter 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>
}
// ---- APIDifferences ----
// PagesRouteruseRouter (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
// AppRouteruseRouter (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 AppRouter ----
// PagesRouter: const { id } = useRouter().query
// AppRouter: 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>ProductID: {id}</div>
}
// ---- Getting path params in AppRouter ----
// PagesRouter: useRouter().query (mixed with query params)
// AppRouter: 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>ProductID: {params.id}</div>
}
// ---- Or use page props directly (ServerComponent) ----
// app/products/[id]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <div>ProductID: {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
// ============================================
// Mistake5: useEffect in LayoutsFiresOnlyOnce
// ============================================
// ---- 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
// PagesDO 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
// ============================================
// Mistake6: BrowserAPIs in ServerComponents
// ============================================
// ---- WRONG: Direct window access in ServerComponent ----
// Thisthrows: ReferenceError: window is not defined
// app/page.tsx (ServerComponent — 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 ServerComponent ----
export default function HomePage() {
const theme = localStorage.getItem('theme') // ERRORreturn <div>Theme: {theme}</div>
}
// ---- WRONG: document in ServerComponent ----
export default function HomePage() {
const title = document.title // ERRORreturn <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' // ERRORif library accesses window
export default function ChartPage() {
return <SomeChartLibrary data={data} />
}
// ---- CORRECT: Move to ClientComponent ----
// 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 (ServerComponent)
import { WindowWidth } from '@/components/window-width'
export default function HomePage() {
return (
<div>
<h1>Home</h1>
<WindowWidth /> {/* ClientComponent — accesses window safely */}
</div>
)
}
// ---- CORRECT: Dynamicimport with ssr: false ----
// For libraries that must access browser APIs at import time
// app/page.tsx
import dynamic from 'next/dynamic'constSomeChartLibrary = 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' // Serverdefault
}
return localStorage.getItem('theme') ?? 'light'
}
// ---- CORRECT: Read cookies/headers in ServerComponent instead ----
// ServerComponentsCAN 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
// ============================================
// Mistake7: Incorrect next/image Usage
// ============================================
// ---- WRONG: No width/height and no fill ----
// Image renders with zero dimensions
importImage 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 ----
// Thisthrows: 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.
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.tsimport { NextResponse } from'next/server'importtype { NextRequest } from'next/server'exportfunctionmiddleware(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 latencyreturnNextResponse.next()
}
// ---- CORRECT: Matcher excludes static assets and API routes (if they handle their own auth) ----exportfunctionmiddleware(request: NextRequest) {
// This runs only on matched routesreturnNextResponse.next()
}
exportconst 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 routesexportconst 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
// ============================================
// Mistake9: MisconfiguredSuspenseBoundaries
// ============================================
// ---- WRONG: NoSuspense 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 for1.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: NestedSuspensefor 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 Suspensefor 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: AutomaticSuspensefor 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
// ============================================
// Mistake10: params and searchParams AreNowPromises
// ============================================
// ---- WRONG: Synchronousdestructuring (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 Promiseconst 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>Layoutfor {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
// ============================================
// Mistake11: Missing or MisconfiguredErrorBoundaries
// ============================================
// ---- 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() // Ifthisthrows, 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 IDfor logging
// reset() — retry the render
return (
<div className="flex flex-col items-center justify-center p-8">
<h2 className="text-xl font-bold">DashboardError</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"
>
TryAgain
</button>
</div>
)
}
// ---- WRONG: error.tsx without 'use client' ----
// error.tsx MUST be a ClientComponent
// app/dashboard/error.tsx
export default function DashboardError({ error, reset }) {
// ERROR: error.tsx must have 'use client' directive
// ServerComponents 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. ErrorID: {error.digest}
</p>
<button
onClick={reset}
className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground"
>
ReloadApplication
</button>
</div>
</div>
</body>
</html>
)
}
// ---- not-found.tsx: Handle 404s ----
// File: app/dashboard/not-found.tsx
export default function DashboardNotFound() {
return (
<div>
<h2>DashboardNotFound</h2>
<p>The requested dashboard does not exist.</p>
</div>
)
}
// Trigger a 404 from a ServerComponent:
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.
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
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
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
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.
Q02 of 05SENIOR
What data can you pass from a Server Component to a Client Component, and what cannot?
ANSWER
Only JSON-serializable data can cross the Server-Client boundary. Serializable types: strings, numbers, booleans, plain objects, arrays, null, undefined, and Date objects (which serialize to ISO strings).
Non-serializable types that cannot be passed: functions, class instances, Map, Set, Symbol, React context providers, DOM nodes, and Promises.
For functions, use Server Actions (marked with 'use server') — the Client Component imports and calls them. Server Actions are the only functions that are serializable references and can be safely passed as props. For Dates, pass the ISO string and reconstruct with new Date(string) on the client. For Maps, convert to a plain object with Object.fromEntries().
The error for functions is explicit. But Date and Map failures are subtle — Date loses prototype methods, Map serializes to an empty object.
Q03 of 05SENIOR
Why does useEffect in a layout only fire once, and how do you handle page tracking?
ANSWER
Layouts in App Router persist across navigations — they stay mounted when the user navigates between pages within the layout's segment. Only the page content swaps. Since useEffect fires on mount and dependency changes, an empty dependency array means the effect fires once on initial load and never again.
For page view tracking, use pathname from usePathname() as the useEffect dependency. The layout persists, but pathname changes on every navigation, which triggers the effect.
Alternatively, move the tracking logic to the page component — pages re-mount on every navigation, so useEffect with an empty dependency array fires correctly. Or use key={pathname} on the children wrapper to force the layout's children to re-mount.
Q04 of 05SENIOR
How do Suspense boundaries affect page loading performance in Next.js 16?
ANSWER
Suspense boundaries control progressive loading — they decide which parts of the page show a loading state while their data loads.
Without any Suspense boundary, the entire page blocks on the slowest data fetch. If one fetch takes 1.5 seconds, the user sees a blank screen for 1.5 seconds.
With one Suspense boundary wrapping the entire page, the same problem occurs — the fallback shows until all children resolve.
With multiple Suspense boundaries around independent data fetches, each section loads progressively. A stats panel that loads in 200ms appears first, an activity feed at 500ms appears second, and notifications at 1.5 seconds appear last. The user sees content incrementally instead of waiting for everything.
This is also how Partial Prerendering (PPR) works in Next.js 16 — the static shell streams first, and Suspense holes fill in dynamically.
The rule: place Suspense boundaries around components with independent data fetches — not around components that share data or around the entire page.
Q05 of 05SENIOR
What is the difference between error.tsx, global-error.tsx, and not-found.tsx in App Router?
ANSWER
error.tsx is an error boundary for a route segment. It catches unhandled errors in its own segment and all child segments. It must be a Client Component ('use client') because it receives interactive props: error (the Error object) and reset (a function to retry the render).
global-error.tsx catches errors in the root layout itself. It is the last resort — it must include <html> and <body> tags because it replaces the entire page. It is only triggered by errors in the root layout, not by errors in child segments.
not-found.tsx handles 404 responses. It is rendered when you call notFound() from a Server Component or when a route segment does not match any page. Unlike error.tsx, it does not receive a reset function — it is a static fallback.
The hierarchy: error.tsx catches segment errors, global-error.tsx catches root layout errors, not-found.tsx handles missing resources. Place error.tsx at each route segment for granular error handling.
01
What changed about fetch() caching in Next.js 15/16, and how do you handle it?
SENIOR
02
What data can you pass from a Server Component to a Client Component, and what cannot?
SENIOR
03
Why does useEffect in a layout only fire once, and how do you handle page tracking?
SENIOR
04
How do Suspense boundaries affect page loading performance in Next.js 16?
SENIOR
05
What is the difference between error.tsx, global-error.tsx, and not-found.tsx in App Router?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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).
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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.