Skip to content
Homeβ€Ί JavaScriptβ€Ί 10 Common Next.js 16 App Router Mistakes (And How to Fix Them)

10 Common Next.js 16 App Router Mistakes (And How to Fix Them)

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 25 of 32
Avoid the most frequent pitfalls in Next.
βš™οΈ Intermediate β€” basic JavaScript knowledge assumed
In this tutorial, you'll learn
Avoid the most frequent pitfalls in Next.
  • fetch() stopped caching by default in Next.js 15 (still true in 16) β€” add explicit cache directives to every fetch call
  • Server-Client props must be JSON-serializable β€” functions, Map, Set, class instances are not allowed (Server Actions are the exception)
  • 'use server' must be the first statement β€” comments are allowed before it
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑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+
🚨 START HERE
Next.js 16 Quick Debug Reference
Fast commands for diagnosing common Next.js 16 issues
🟑Stale data on pages
Immediate ActionFind 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 NowAdd { next: { revalidate: 3600 } } or { cache: 'force-cache' } to every fetch call
🟑Serialization error in Server Component
Immediate ActionFind 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 NowPass only JSON-serializable data (strings, numbers, booleans, plain objects, arrays) across the Server-Client boundary
🟑useRouter not found or undefined
Immediate ActionCheck 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 NowChange import from 'next/router' to 'next/navigation' β€” Pages Router API is not available in App Router
🟑Image optimization failing
Immediate ActionCheck next.config for images configuration
Commands
cat next.config.ts | grep -A 10 'images'
ls -la public/images/ | head -10
Fix NowAdd images.remotePatterns in next.config.ts for external images, or use width/height props for local images
Production Incidentfetch() Caching Change Caused 3x API Costs and +400ms Latency After UpgradeA team upgraded from Next.js 14 to Next.js 15/16. Their product listing pages started making fresh API calls on every render because fetch() no longer cached by default. They added a 6-hour in-memory cache "to fix performance" β€” which caused stale prices for hours.
SymptomAPI 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.
Assumptionfetch() responses were still cached by default (like in Next.js 14) and revalidated by the ISR revalidate setting on the page.
Root causeNext.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.
FixAdded 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 issues
Page shows stale data despite CMS update→Check fetch calls for explicit cache directives — Next.js 15/16 does not cache fetch by default
Error: Functions cannot be passed from Server to Client Component→Remove function props from Server-to-Client boundary — pass serializable data and handle logic in the Client Component (Server Actions are the exception)
Server Action returns 500 or does not execute→Verify 'use server' directive is the first statement in the file or at the top of the function body (comments are allowed)
useRouter.push() is undefined or throws→Import useRouter from 'next/navigation' — not 'next/router' which is Pages Router only
useEffect in layout does not fire on navigation→Layouts persist across navigations — move useEffect to the page component or use a key prop
Hydration mismatch error on page load→Check for Client Components rendering different content on server vs client — browser APIs, Date.now(), Math.random()
next/image shows broken image or wrong dimensions→Verify width/height or fill prop is set, and images.domains or images.remotePatterns is configured in next.config
Middleware runs on static assets causing latency→Update the matcher to exclude _next/static, _next/image, favicon.ico, and static file extensions

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.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// ============================================
// 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)
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// ============================================
// 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
Mental Model
The Serialization Boundary
Server and Client Components communicate via JSON serialization β€” only JSON-safe data crosses the 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.ts Β· TYPESCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
// ============================================
// 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
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// ============================================
// 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
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
// ============================================
// 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>
  )
}
Mental Model
Layouts Persist, Pages Re-Mount
Layouts stay mounted across navigations β€” only the page content swaps. useEffect in layouts fires once.
  • 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// ============================================
// 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
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
// ============================================
// 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.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ============================================
// 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
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// ============================================
// 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>
  )
}
Mental Model
Suspense Controls Progressive Loading
Suspense boundaries decide what shows a loading state while data loads β€” place them around independent data fetches.
  • 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.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// ============================================
// 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
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
// ============================================
// 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
πŸ“Š 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.
πŸ—‚ Pages Router vs App Router: Key Differences
APIs that changed or were removed in App Router
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

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

⚠ Common Mistakes to Avoid

    βœ•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 Questions on This Topic

  • QWhat changed about fetch() caching in Next.js 15/16, and how do you handle it?Mid-levelReveal
    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.
  • QWhat data can you pass from a Server Component to a Client Component, and what cannot?Mid-levelReveal
    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.
  • QWhy does useEffect in a layout only fire once, and how do you handle page tracking?Mid-levelReveal
    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.
  • QHow do Suspense boundaries affect page loading performance in Next.js 16?SeniorReveal
    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.
  • QWhat is the difference between error.tsx, global-error.tsx, and not-found.tsx in App Router?SeniorReveal
    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.

Frequently Asked Questions

Can I still use the Pages Router in Next.js 16?

Yes, the Pages Router is still supported in Next.js 16. Both routers can coexist in the same project β€” Pages Router in the pages/ directory and App Router in the app/ directory. However, new features and optimizations are focused on the App Router. For new projects, the App Router is recommended.

How do I migrate from getServerSideProps to App Router data fetching?

Remove getServerSideProps and make the page component async. Fetch data directly inside the component using fetch() with { cache: 'no-store' } for dynamic data (equivalent to getServerSideProps). The component renders on the server with the fetched data. For static data, use { cache: 'force-cache' } (equivalent to getStaticProps).

What happens if I forget to add 'use server' to a Server Action?

The function runs on the client instead of the server. If it accesses a database or other server-only resource, it fails with a connection error or ReferenceError. If it only uses client-safe APIs, it runs without error but on the client β€” which may expose sensitive logic or cause unexpected behavior.

Can I use Suspense with Server Components?

Yes, Suspense works with Server Components. When you wrap an async Server Component in a Suspense boundary, Next.js streams the component's output. The fallback is shown immediately, and the actual content replaces it when the async component resolves. This is the primary mechanism for streaming in App Router.

How do I handle authentication errors in middleware?

In middleware, check the user session with supabase.auth.getUser() or your auth provider's equivalent. If the user is not authenticated and the route is protected, redirect to the login page. If the token is expired, the middleware should refresh it automatically. Return NextResponse.redirect(new URL('/login', request.url)) for unauthenticated users on protected routes.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousBuilding Type-Safe Forms with Zod, React Hook Form & Next.js 16Next β†’Supabase Auth with Next.js 16 β€” The Complete 2026 Guide
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged