Next.js 16 fetch() Caching — Why Your API Costs Tripled
Next.js 15 silently changed fetch() defaults.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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+
Next.js 16 changed the default caching behavior of from persistent (stale-while-revalidate with a 30-second TTL) to fetch()no-store — meaning every request hits your origin server. This was a deliberate shift to align with the web platform's default semantics, but it caught teams off guard who relied on the old implicit caching to absorb repeated API calls across server components, layouts, and parallel routes.fetch()
The result: API costs that can triple overnight, especially on pages with heavy data dependencies or high traffic. If you're migrating from Next.js 14 or earlier, or you assumed still caches by default, you're paying for every render — not just every unique request.fetch()
The fix is explicit: add next: { revalidate: 60 } or use a shared cache layer like React's or a dedicated data-fetching library. This isn't a bug — it's a breaking change that demands you rethink your data strategy.cache()
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Why Next.js 16 fetch() Caching Blew Up Your API Bill
Next.js 16 changed the default caching behavior of fetch() from 'force-cache' to 'no-store' for server-side data fetching. This means every request to your API now hits the origin server instead of reusing a cached response. The core mechanic is simple: the fetch() cache option now defaults to 'no-store' unless you explicitly opt in with 'force-cache' or 'next: { revalidate }'. This shift was intended to reduce stale data risks, but it silently triples API costs for teams that relied on the old defaults.
In practice, this affects every page that uses fetch() inside Server Components, getServerSideProps, or route handlers. Without a cache header or explicit revalidation, each user request triggers a fresh fetch. For a page with 10 API calls and 1000 daily users, that's 10,000 extra requests per day — a 3x increase over the previous default of caching until revalidation. The key property is that Next.js 16 respects the HTTP Cache-Control header if present, but many internal APIs lack this header, leaving you with no caching at all.
You must use this knowledge to audit every fetch() call in your codebase. Add 'next: { revalidate: 60 }' for data that changes infrequently, or set 'force-cache' for static reference data. For user-specific data, 'no-store' is correct — but for shared data, you're burning money. This matters because a single overlooked fetch() in a layout component can multiply your API costs by the number of pages that mount that layout.
fetch() defaults to 'no-store'. Your API bill spikes silently until you audit every call.fetch() defaulted to no-store.fetch() — never rely on defaults.fetch() defaults to 'no-store' — you must opt into caching.fetch() call; add 'next: { revalidate }' or 'force-cache' for shared data.Mistake 1: Assuming fetch() Caches by Default
This is the most impactful change that landed in Next.js 15 and is still true in Next.js 16. In Next.js 13-14, fetch() responses were cached by default — equivalent to { cache: 'force-cache' }. In Next.js 15/16, fetch() does NOT cache by default. It behaves like native fetch, making a fresh HTTP request on every call.
This means pages that relied on implicit fetch caching now make redundant API calls on every render. If you have ISR (Incremental Static Regeneration) configured, the page revalidates on schedule, but the fetch inside the page makes a fresh request each time instead of returning a cached response.
The fix is explicit: add a cache directive to every fetch call. Use { cache: 'force-cache' } for permanent caching, { next: { revalidate: seconds } } for time-based revalidation, or { cache: 'no-store' } for always-fresh data.
// ============================================ // Mistake 1: fetch() Stopped Caching by Default in Next.js 15/16 // ============================================ // ---- WRONG: No cache directive ---- // This makes a fresh HTTP request on EVERY render // In Next.js 14 this was cached — in Next.js 15/16 it is NOT async function getProducts() { const res = await fetch('https://api.example.com/products') return res.json() } // ---- CORRECT: Explicit cache directive ---- // Option A: Cache permanently (static data) async function getProductsCached() { const res = await fetch('https://api.example.com/products', { cache: 'force-cache', }) return res.json() } // Option B: Time-based revalidation (revalidate every hour) async function getProductsRevalidated() { const res = await fetch('https://api.example.com/products', { next: { revalidate: 3600 }, }) return res.json() } // Option C: On-demand revalidation (revalidate when you call revalidateTag) async function getProductsTagged() { const res = await fetch('https://api.example.com/products', { next: { tags: ['products'] }, }) return res.json() } // Then revalidate on demand: // import { revalidateTag } from 'next/cache' // revalidateTag('products') // Option D: Never cache (always fresh — for user-specific data) async function getUserProfile() { const res = await fetch('https://api.example.com/profile', { cache: 'no-store', }) return res.json() } // ---- Production impact of missing cache directive ---- // Without caching: // - Every page load triggers an API call // - API rate limits are hit faster // - Page load time increases (network latency on every request) // - ISR revalidation is effectively useless // - Server costs increase from redundant compute
- 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
Mistake 2: Passing Non-Serializable Data to Client Components
Server Components render on the server and pass props to Client Components. The props must be JSON-serializable: strings, numbers, booleans, plain objects, arrays, null, undefined, and Date objects. Functions, class instances, symbols, and React context providers cannot be passed across the Server-Client boundary.
The error is clear: "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'." But developers encounter subtler versions: passing a Date object that loses its prototype, passing a Map or Set that serializes to an empty object, or passing a callback that silently becomes undefined.
The fix: pass only serializable data. If the Client Component needs a function, define it inside the Client Component or use a Server Action. If it needs a Date, pass the ISO string and reconstruct it on the client.
// ============================================ // Mistake 2: Non-Serializable Props Across Server-Client Boundary // ============================================ // ---- WRONG: Passing a function from Server to Client ---- // This throws: "Functions cannot be passed to Client Components" // app/page.tsx (Server Component) import { ProductCard } from '@/components/product-card' export default async function ProductsPage() { const products = await getProducts() function handleAddToCart(productId: string) { // This function cannot be passed to a Client Component console.log('Added', productId) } return ( <div> {products.map((product) => ( <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} // ERROR: function is not serializable /> ))} </div> ) } // ---- CORRECT: Use a Server Action (the loophole) ---- // Server Actions are serializable references and CAN be passed // app/actions.ts 'use server' export async function addToCart(productId: string) { // Server Action — runs on the server // Can be called from Client Components console.log('Added', productId) } // components/product-card.tsx 'use client' import { addToCart } from '@/app/actions' export function ProductCard({ product }: { product: Product }) { return ( <div> <h3>{product.name}</h3> <button onClick={() => addToCart(product.id)}> Add to Cart </button> </div> ) } // ---- WRONG: Passing Date object ---- // Date serializes to ISO string — loses prototype methods // Server Component export default async function Page() { const event = await getEvent() return <EventCard date={event.date} /> // event.date is a Date object } // Client Component 'use client' export function EventCard({ date }: { date: Date }) { // date.toRelative() may not work — prototype methods lost // date instanceof Date is false after serialization return <p>{date.toLocaleDateString()}</p> } // ---- CORRECT: Pass ISO string, reconstruct on client ---- // Server Component export default async function Page() { const event = await getEvent() return <EventCard date={event.date.toISOString()} /> } // Client Component 'use client' export function EventCard({ date }: { date: string }) { const dateObj = new Date(date) return <p>{dateObj.toLocaleDateString()}</p> } // ---- WRONG: Passing Map or Set ---- // Map serializes to {} — all data is lost // Server Component export default async function Page() { const map = new Map([['key', 'value']]) return <Display data={map} /> // ERROR: Map serializes to {} } // ---- CORRECT: Convert to plain object ---- // Server Component export default async function Page() { const map = new Map([['key', 'value']]) const plain = Object.fromEntries(map) return <Display data={plain} /> } // ---- WRONG: Passing class instance ---- // Class instances lose their methods after serialization class Calculator { add(a: number, b: number) { return a + b } } // Server Component export default async function Page() { const calc = new Calculator() return <CalcDisplay calc={calc} /> // ERROR: methods are lost } // ---- CORRECT: Pass data, not instances ---- // Move the logic to the Client Component or use a Server Action
- Serializable: strings, numbers, booleans, plain objects, arrays, null, undefined, Date (as ISO string)
- Not serializable: functions, class instances, Map, Set, Symbol, React context, DOM nodes
- Server Actions (marked with 'use server') are the exception — they are serializable references and CAN be passed
- If you need a function in a Client Component, define it there or use a Server Action
- If you need a Date, pass the ISO string and reconstruct with new Date(string) on the client
- The error message is clear — but subtle cases (Map, Date prototype) fail silently
Mistake 3: Missing 'use server' Directive in Server Actions
Server Actions must be explicitly marked with the 'use server' directive. Without it, the function runs on the client (or throws an error). The directive can be placed at the top of a file (all exported functions become Server Actions) or at the top of an individual function body.
A common mistake: placing 'use server' after imports or after the function declaration. The directive must be the FIRST statement of the file or the FIRST statement of the function body. Comments are allowed before it.
// ============================================ // Mistake 3: Missing or Misplaced 'use server' Directive // ============================================ // ---- WRONG: 'use server' not the first statement ---- // This file has a comment before the directive — it is ignored // File: app/actions.ts // This is my server actions file 'use server' // ERROR: not the first statement — directive is ignored export async function createPost(title: string) { // This runs on the client, not the server // Database calls fail because the client cannot access the DB } // ---- CORRECT: 'use server' as the first statement (comments allowed) ---- // File: app/actions.ts // Comments are allowed before the directive 'use server' import { prisma } from '@/lib/db' export async function createPost(title: string) { // This runs on the server — DB access works return prisma.post.create({ data: { title } }) } // ---- WRONG: 'use server' after function ---- export async function deletePost(id: string) { 'use server' // ERROR: must be the FIRST statement of the function body return prisma.post.delete({ where: { id } }) } // ---- CORRECT: 'use server' as first statement of function body ---- export async function deletePost(id: string) { 'use server' const prisma = (await import('@/lib/db')).prisma return prisma.post.delete({ where: { id } }) } // ---- WRONG: Passing non-async function as Server Action ---- // Server Actions must be async functions 'use server' export function createPostSync(title: string) { // ERROR: Server Actions must be async return prisma.post.create({ data: { title } }) } // ---- CORRECT: Async function ---- 'use server' export async function createPostAsync(title: string) { return prisma.post.create({ data: { title } }) } // ---- WRONG: Server Action with non-serializable parameters ---- // FormData is serializable — but Map, Set, class instances are not 'use server' export async function processData(data: Map<string, string>) { // ERROR: Map is not serializable from FormData } // ---- CORRECT: Accept FormData or serializable types ---- 'use server' export async function processData(formData: FormData) { const title = formData.get('title') as string const content = formData.get('content') as string // Process serializable data } // ---- Server Action with return value ---- // Return values must also be serializable 'use server' export async function getPost(id: string) { const post = await prisma.post.findUnique({ where: { id } }) return post // Plain object — serializable // Do NOT return functions, class instances, or React elements }
- '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
Mistake 4: Using useRouter from next/router in App Router
The Pages Router uses 'next/router' for navigation. The App Router uses 'next/navigation'. These are different APIs with different methods. Importing from 'next/router' in an App Router project either throws an error or returns undefined methods.
The key difference: the Pages Router useRouter has isReady, asPath, and query. The App Router usePathname, useSearchParams, and useRouter from 'next/navigation' have a different API. useRouter in App Router only has push, replace, refresh, back, forward, and prefetch — no isReady, no asPath, no query.
This mistake is common when migrating from Pages Router to App Router or when copying code from older tutorials.
// ============================================ // Mistake 4: Wrong Router Import in App Router // ============================================ // ---- WRONG: Pages Router import in App Router ---- // This import path does not exist in App Router context 'use client' import { useRouter } from 'next/router' // ERROR: Pages Router only export function LoginForm() { const router = useRouter() async function handleLogin() { await login() router.push('/dashboard') // This may throw or be undefined } return <button onClick={handleLogin}>Login</button> } // ---- CORRECT: App Router imports ---- 'use client' import { useRouter, usePathname, useSearchParams } from 'next/navigation' export function LoginForm() { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() async function handleLogin() { await login() router.push('/dashboard') } return <button onClick={handleLogin}>Login</button> } // ---- API Differences ---- // Pages Router useRouter (next/router): // - router.push(url) — navigate // - router.replace(url) — navigate without history // - router.back() — go back // - router.query — URL query params (object) // - router.asPath — current path with query string // - router.isReady — true when router is initialized // - router.pathname — current path without query // App Router useRouter (next/navigation): // - router.push(url) — navigate // - router.replace(url) — navigate without history // - router.refresh() — refresh current page (no full reload) // - router.back() — go back // - router.forward() — go forward // - router.prefetch(url) — prefetch a route // - NO query, NO asPath, NO isReady // ---- Getting query params in App Router ---- // Pages Router: const { id } = useRouter().query // App Router: use useSearchParams() hook 'use client' import { useSearchParams } from 'next/navigation' export function ProductPage() { const searchParams = useSearchParams() const id = searchParams.get('id') // Get single param const sort = searchParams.get('sort') // Get single param // Get all params const allParams = Object.fromEntries(searchParams.entries()) return <div>Product ID: {id}</div> } // ---- Getting path params in App Router ---- // Pages Router: useRouter().query (mixed with query params) // App Router: useParams() hook or page props 'use client' import { useParams } from 'next/navigation' export function ProductPage() { const params = useParams() // params.id comes from app/products/[id]/page.tsx return <div>Product ID: {params.id}</div> } // ---- Or use page props directly (Server Component) ---- // app/products/[id]/page.tsx export default async function ProductPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params return <div>Product ID: {id}</div> }
- 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
Mistake 5: useEffect in Layouts Does Not Fire on Navigation
Layouts in App Router persist across navigations. When a user navigates from /dashboard to /settings, the dashboard layout stays mounted — only the page content changes. This means useEffect in a layout fires once on initial load and never again on subsequent navigations.
This breaks patterns that rely on useEffect to track page views, reset form state, or fetch data on route change. Developers expect the layout to re-mount on navigation — it does not.
// ============================================ // Mistake 5: useEffect in Layouts Fires Only Once // ============================================ // ---- WRONG: Page view tracking in layout ---- // This fires only on the FIRST page load — not on navigation // app/(dashboard)/layout.tsx 'use client' import { useEffect } from 'react' import { usePathname } from 'next/navigation' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const pathname = usePathname() useEffect(() => { // This fires only on initial mount — NOT on navigation // Page views are not tracked after the first page analytics.track('page_view', { path: pathname }) }, []) // Empty dependency array — fires once return <div>{children}</div> } // ---- CORRECT: Track page views with pathname dependency ---- // The layout persists, but pathname changes — use it as dependency // app/(dashboard)/layout.tsx 'use client' import { useEffect } from 'react' import { usePathname } from 'next/navigation' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const pathname = usePathname() useEffect(() => { // This fires on EVERY pathname change analytics.track('page_view', { path: pathname }) }, [pathname]) // pathname is the dependency return <div>{children}</div> } // ---- WRONG: Resetting form state in layout ---- // Layout persists — form state is not reset on navigation // app/(dashboard)/layout.tsx 'use client' import { useState } from 'react' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const [formData, setFormData] = useState({ name: '', email: '' }) // This state persists across navigations // User navigates away and back — form data is still filled return <div>{children}</div> } // ---- CORRECT: Use key prop or reset in page component ---- // Reset form state in the page component, not the layout // app/(dashboard)/settings/page.tsx 'use client' import { useState, useEffect } from 'react' import { usePathname } from 'next/navigation' export default function SettingsPage() { const [formData, setFormData] = useState({ name: '', email: '' }) // Reset form when the page mounts // Pages DO re-mount on navigation (unlike layouts) useEffect(() => { setFormData({ name: '', email: '' }) }, []) return ( <form> <input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} /> </form> ) } // ---- Alternative: Use key prop to force re-mount ---- // app/(dashboard)/layout.tsx 'use client' import { usePathname } from 'next/navigation' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const pathname = usePathname() return ( <div> {/* key forces re-mount when pathname changes */} <div key={pathname}>{children}</div> </div> ) }
- Layouts persist across navigations — they do not re-mount or re-run effects
- Pages re-mount on navigation — useEffect in pages fires on every navigation
- For page view tracking in layouts, use pathname as the useEffect dependency — not an empty array
- For form state reset, handle it in the page component — not the layout
- Use key={pathname} on the children wrapper to force re-mount if needed
Mistake 6: Accessing Browser APIs in Server Components
Server Components render on the server — they have no access to browser APIs like window, document, localStorage, sessionStorage, navigator, or location. Accessing any of these in a Server Component throws a ReferenceError during server-side rendering.
The error is obvious for direct access (window is not defined). The subtle version: importing a library that accesses browser APIs internally (like a date picker that checks window.innerWidth during import). The error occurs at import time, not at render time, which makes it harder to trace.
// ============================================ // Mistake 6: Browser APIs in Server Components // ============================================ // ---- WRONG: Direct window access in Server Component ---- // This throws: ReferenceError: window is not defined // app/page.tsx (Server Component — no 'use client') export default function HomePage() { const width = window.innerWidth // ERROR: window is not defined on server return <div>Window width: {width}</div> } // ---- WRONG: localStorage in Server Component ---- export default function HomePage() { const theme = localStorage.getItem('theme') // ERROR return <div>Theme: {theme}</div> } // ---- WRONG: document in Server Component ---- export default function HomePage() { const title = document.title // ERROR return <div>Title: {title}</div> } // ---- WRONG: Library that accesses browser APIs at import time ---- // Some libraries check window during module initialization // app/page.tsx import { SomeChartLibrary } from 'react-chart-lib' // ERROR if library accesses window export default function ChartPage() { return <SomeChartLibrary data={data} /> } // ---- CORRECT: Move to Client Component ---- // components/window-width.tsx 'use client' import { useState, useEffect } from 'react' export function WindowWidth() { const [width, setWidth] = useState(0) useEffect(() => { // useEffect runs only on the client — window is available setWidth(window.innerWidth) const handleResize = () => setWidth(window.innerWidth) window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) return <div>Window width: {width}</div> } // app/page.tsx (Server Component) import { WindowWidth } from '@/components/window-width' export default function HomePage() { return ( <div> <h1>Home</h1> <WindowWidth /> {/* Client Component — accesses window safely */} </div> ) } // ---- CORRECT: Dynamic import with ssr: false ---- // For libraries that must access browser APIs at import time // app/page.tsx import dynamic from 'next/dynamic' const SomeChartLibrary = dynamic( () => import('react-chart-lib').then((mod) => mod.SomeChartLibrary), { ssr: false, loading: () => <div>Loading chart...</div> } ) export default function ChartPage() { return <SomeChartLibrary data={data} /> } // ---- CORRECT: Conditional access with typeof check ---- // For utility functions that may run on server or client function getTheme(): string { if (typeof window === 'undefined') { return 'light' // Server default } return localStorage.getItem('theme') ?? 'light' } // ---- CORRECT: Read cookies/headers in Server Component instead ---- // Server Components CAN access cookies and headers import { cookies, headers } from 'next/headers' export default async function HomePage() { const cookieStore = await cookies() const theme = cookieStore.get('theme')?.value ?? 'light' const headersList = await headers() const userAgent = headersList.get('user-agent') return <div>Theme: {theme}, UA: {userAgent}</div> }
- 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()andheaders()from next/headers — use these instead of browser storage
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).
// ============================================ // Mistake 7: Incorrect next/image Usage // ============================================ // ---- WRONG: No width/height and no fill ---- // Image renders with zero dimensions import Image from 'next/image' export function ProductCard() { return ( <Image src="/product.jpg" alt="Product" // ERROR: missing width, height, or fill /> ) } // ---- CORRECT: Fixed size with width and height ---- export function ProductCard() { return ( <Image src="/product.jpg" alt="Product" width={400} height={300} className="rounded-lg" /> ) } // ---- CORRECT: Responsive with fill ---- // Parent must have position: relative export function ProductCard() { return ( <div className="relative h-64 w-full"> <Image src="/product.jpg" alt="Product" fill className="object-cover rounded-lg" /> </div> ) } // ---- WRONG: External image without domain config ---- // This throws: Invalid src prop on next/image export function Avatar() { return ( <Image src="https://cdn.example.com/avatar.jpg" alt="Avatar" width={100} height={100} // ERROR: cdn.example.com is not configured /> ) } // ---- CORRECT: Configure remotePatterns in next.config ---- // next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com', pathname: '/**', }, { protocol: 'https', hostname: '*.supabase.co', pathname: '/storage/v1/object/public/**', }, ], }, } export default nextConfig // ---- WRONG: Using regular <img> tag ---- // Loses: automatic optimization, lazy loading, responsive sizing, // WebP/AVIF conversion, blur placeholder export function Hero() { return <img src="/hero.jpg" alt="Hero" /> // Works but unoptimized } // ---- CORRECT: Use next/image ---- export function Hero() { return ( <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority // Load immediately — skip lazy loading placeholder="blur" // Show blur placeholder while loading blurDataURL="data:image/jpeg;base64,..." // Base64 blur image /> ) } // ---- Using sizes prop for responsive images ---- // Without sizes, the browser downloads the largest variant export function ProductGrid() { return ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> {products.map((product) => ( <Image key={product.id} src={product.image} alt={product.name} fill sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="object-cover" /> ))} </div> ) }
- Always provide width/height (fixed) or fill (responsive) — without either, the image has zero dimensions
- External images require remotePatterns configuration in next.config — wildcards are supported
- Use fill with position: relative on the parent — otherwise the image overflows
- Add sizes prop for responsive images — without it, the browser downloads the largest variant
- Use priority for above-the-fold images — it skips lazy loading and preloads the image
Mistake 8: Misconfigured Middleware Matcher
Middleware runs on every request by default — including static assets like images, fonts, and JavaScript bundles. This adds unnecessary latency because each static asset request triggers the middleware function, which may include auth checks, redirects, or other logic.
The matcher configuration in middleware.ts controls which routes the middleware runs on. Without a matcher, it runs on everything. With an incorrect matcher, it may skip routes that need protection or include routes that should be excluded.
// ============================================ // Mistake 8: Middleware Runs on Static Assets // ============================================ // ---- WRONG: No matcher — middleware runs on EVERYTHING ---- // File: middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { // This runs on: // /dashboard (intended) // /api/users (intended) // /_next/static/chunks/main.js (unintended — adds latency) // /_next/image?url=... (unintended — adds latency) // /favicon.ico (unintended) // /images/logo.png (unintended) // Each static asset request adds 5-50ms of middleware overhead // A page with 20 static assets adds 100ms-1s of total latency return NextResponse.next() } // ---- CORRECT: Matcher excludes static assets and API routes (if they handle their own auth) ---- export function middleware(request: NextRequest) { // This runs only on matched routes return NextResponse.next() } export const config = { matcher: [ /* * Match all request paths except: * - api (API routes handle their own auth) * - _next/static (static files) * - _next/image (image optimization) * - favicon.ico (favicon) * - Static file extensions (images, fonts, etc.) */ '/((?!api|_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } // ---- Matcher for specific routes only ---- // If middleware only needs to protect certain routes export const configSpecific = { matcher: ['/dashboard/:path*', '/settings/:path*', '/admin/:path*'], } // ---- Performance impact measurement ---- // Without matcher: // - 50 static assets per page = 50 middleware invocations // - Average middleware latency: 10ms // - Total added latency: 500ms // // With matcher: // - 1 page request = 1 middleware invocation // - Total added latency: 10ms // - Improvement: 490ms (98% reduction)
- 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
Mistake 9: Not Understanding Streaming and Suspense Boundaries
Next.js 16 uses React Suspense for streaming — pages render progressively, with loading states shown while async data loads. Without Suspense boundaries, the entire page blocks on the slowest data fetch. With too many boundaries, the page shows excessive loading spinners.
The key insight: Suspense boundaries control what shows a loading state while data loads. Place them around components that fetch data independently. Do not wrap the entire page in a single boundary — that defeats the purpose of streaming.
This is also how Partial Prerendering (PPR) works in Next.js 16 — the static shell streams first, and Suspense holes fill in dynamically.
// ============================================ // Mistake 9: Misconfigured Suspense Boundaries // ============================================ // ---- WRONG: No Suspense boundary ---- // The entire page blocks on the slowest fetch // app/dashboard/page.tsx export default async function DashboardPage() { // Both fetches must complete before ANY content is shown const [stats, activity, notifications] = await Promise.all([ getStats(), // 200ms getActivity(), // 500ms getNotifications() // 1500ms ← slowest — blocks everything ]) return ( <div> <StatsPanel data={stats} /> <ActivityFeed data={activity} /> <Notifications data={notifications} /> </div> ) // User sees nothing for 1.5 seconds — then everything appears at once } // ---- CORRECT: Suspense boundaries for independent sections ---- // Each section loads independently — user sees content progressively // app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div> <Suspense fallback={<StatsPanelSkeleton />}> <StatsPanel /> {/* Fetches independently — 200ms */} </Suspense> <Suspense fallback={<ActivityFeedSkeleton />}> <ActivityFeed /> {/* Fetches independently — 500ms */} </Suspense> <Suspense fallback={<NotificationsSkeleton />}> <Notifications /> {/* Fetches independently — 1500ms */} </Suspense> </div> ) // Stats appear at 200ms, activity at 500ms, notifications at 1500ms } // components/stats-panel.tsx async function StatsPanel() { const stats = await getStats() // 200ms return <div>{/* render stats */}</div> } // ---- WRONG: Wrapping everything in one Suspense ---- // This is equivalent to having no Suspense — the slowest blocks all export default function DashboardPage() { return ( <Suspense fallback={<PageSkeleton />}> <StatsPanel /> <ActivityFeed /> <Notifications /> </Suspense> ) // Fallback shows until ALL three resolve — same as no Suspense } // ---- CORRECT: Nested Suspense for different loading priorities ---- // Critical content loads first, secondary content loads later export default function DashboardPage() { return ( <div> {/* Critical: always show immediately */} <Header /> {/* Primary content: show skeleton while loading */} <Suspense fallback={<StatsPanelSkeleton />}> <StatsPanel /> </Suspense> {/* Secondary content: nested Suspense for fine-grained control */} <div className="grid grid-cols-2 gap-4"> <Suspense fallback={<ActivityFeedSkeleton />}> <ActivityFeed /> </Suspense> <Suspense fallback={<NotificationsSkeleton />}> <Notifications /> </Suspense> </div> </div> ) } // ---- loading.tsx: Automatic Suspense for page segments ---- // app/dashboard/loading.tsx // This file creates an automatic Suspense boundary for the page export default function DashboardLoading() { return <div className="animate-pulse">Loading dashboard...</div> } // ---- error.tsx: Error boundary for page segments ---- // app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div> <h2>Something went wrong</h2> <button onClick={() => reset()}>Try again</button> </div> ) }
- No Suspense: the entire page blocks on the slowest fetch — user sees nothing until everything loads
- One Suspense wrapping everything: same problem — fallback shows until all children resolve
- Multiple Suspense boundaries: each section loads independently — user sees content progressively
- This is how Partial Prerendering (PPR) works in Next.js 16 — static shell streams first, Suspense holes fill in
- loading.tsx creates automatic Suspense boundaries for page segments
Mistake 10: params and searchParams Are Now Promises (Next.js 15+)
In Next.js 15 and 16, the params and searchParams props passed to page components and layout components are now Promises. This change was introduced to support async params in dynamic routes.
The old synchronous destructuring (const { id } = params) no longer works and will throw or return undefined. You must await the Promise first.
// ============================================ // Mistake 10: params and searchParams Are Now Promises // ============================================ // ---- WRONG: Synchronous destructuring (Next.js 14 and earlier) ---- // This breaks in Next.js 15/16 // app/products/[id]/page.tsx export default function ProductPage({ params, searchParams, }: { params: { id: string } searchParams: { tab?: string } }) { const id = params.id // ERROR: params is now a Promise const tab = searchParams.tab return <div>Product {id}</div> } // ---- CORRECT: Await the Promise (Next.js 15/16) ---- export default async function ProductPage({ params, searchParams, }: { params: Promise<{ id: string }> searchParams: Promise<{ tab?: string }> }) { const { id } = await params const { tab } = await searchParams return <div>Product {id} — Tab: {tab}</div> } // ---- WRONG: In layout.tsx (same rule applies) ---- // app/[slug]/layout.tsx export default function Layout({ params, }: { params: { slug: string } }) { const slug = params.slug // ERROR } // ---- CORRECT: Await in layout (layouts can be async) ---- export default async function Layout({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params return <div>Layout for {slug}</div> } // ---- Production impact ---- // Without await: params.id is undefined or the whole object is a Promise // Page renders with wrong data or crashes on .id access
- 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
Mistake 11: Missing Error Boundaries and error.tsx
Next.js App Router uses error.tsx files as error boundaries for route segments. Without them, an unhandled error in a Server Component crashes the entire application — the user sees a blank page or a raw error stack trace.
The error.tsx file must be a Client Component ('use client') because it receives error and reset props that are interactive. It catches errors in its own segment and all child segments — but not in the parent layout.
A common mistake: placing error.tsx only at the root level. This catches all errors but shows a generic error page for every failure. Place error.tsx at each route segment for granular error handling — a failed dashboard widget should not crash the entire application.
// ============================================ // Mistake 11: Missing or Misconfigured Error Boundaries // ============================================ // ---- WRONG: No error.tsx anywhere ---- // An unhandled error crashes the entire app // User sees: blank page, raw stack trace, or browser error // app/dashboard/page.tsx export default async function DashboardPage() { const stats = await getStats() // If this throws, the entire page crashes return <div>{stats.total}</div> } // ---- CORRECT: error.tsx at each route segment ---- // File: app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { // error.message — the error message // error.digest — unique error ID for logging // reset() — retry the render return ( <div className="flex flex-col items-center justify-center p-8"> <h2 className="text-xl font-bold">Dashboard Error</h2> <p className="text-muted-foreground mt-2"> Something went wrong loading the dashboard. </p> <button onClick={reset} className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground" > Try Again </button> </div> ) } // ---- WRONG: error.tsx without 'use client' ---- // error.tsx MUST be a Client Component // app/dashboard/error.tsx export default function DashboardError({ error, reset }) { // ERROR: error.tsx must have 'use client' directive // Server Components cannot receive interactive props like reset return <div>Error</div> } // ---- CORRECT: Granular error boundaries per segment ---- // app/layout.tsx — Root error boundary (catches unhandled errors) // app/error.tsx — catches errors in all child routes // app/dashboard/error.tsx — catches errors in dashboard and its children // app/dashboard/settings/error.tsx — catches errors in settings specifically // app/dashboard/analytics/error.tsx — catches errors in analytics specifically // ---- Global error boundary for root layout errors ---- // File: app/global-error.tsx // Catches errors in the root layout itself // Must include <html> and <body> tags 'use client' export default function GlobalError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <html> <body> <div className="flex min-h-screen items-center justify-center"> <div className="text-center"> <h2 className="text-2xl font-bold">Something went wrong</h2> <p className="text-muted-foreground mt-2"> An unexpected error occurred. Error ID: {error.digest} </p> <button onClick={reset} className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground" > Reload Application </button> </div> </div> </body> </html> ) } // ---- not-found.tsx: Handle 404s ---- // File: app/dashboard/not-found.tsx export default function DashboardNotFound() { return ( <div> <h2>Dashboard Not Found</h2> <p>The requested dashboard does not exist.</p> </div> ) } // Trigger a 404 from a Server Component: import { notFound } from 'next/navigation' export default async function DashboardPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const dashboard = await getDashboard(id) if (!dashboard) { notFound() // Renders the nearest not-found.tsx } return <div>{dashboard.name}</div> }
- 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
Error Boundaries Are Not Optional — Here’s How Nested Routes Will Bite You
You think one global error boundary protects your entire app? Wrong. Your layout’s error boundary won’t catch errors thrown inside nested page components. That’s by design — and it’s a feature, not a bug. Each route segment gets its own error boundary via error.js, and if you don’t create them, errors in child routes bubble up the parent chain until something catches them. If nothing catches them? Blank screen. User rage. You get paged at 3 AM. Next.js follows the React error boundary pattern, where boundaries catch rendering errors only for their subtree. So if your /dashboard/settings/ route throws, but you only have a error.js at /dashboard, it catches it. Miss both? Congratulations, your user sees nothing. Match your error handling to your route hierarchy. Every app directory that has a layout should have an error.js. Every critical page branch should have one too. It’s cheap insurance.
// io.thecodeforge — javascript tutorial // app/dashboard/settings/error.js 'use client' export default function SettingsError({ error, reset }) { return ( <div className="error-card"> <h2>Settings panel crashed</h2> <p>{error.message}</p> <button onClick={() => reset()}>Retry</button> </div> ) }
reset() Is Not a Magic Wand — You Still Need to Fix the Root Cause
The reset() function in error.js re-renders the component that threw the error. That’s it. It doesn’t clear stale state, flush caches, or re-run any server-side data fetching logic. If your error was caused by a missing API key, reset() will re-run the same broken code and the same error will show up. Real talk: reset() is useful for transient errors. Think network blips, race conditions, or third-party script timing issues where retrying actually works. But for permanent failures — like a malformed URL param, a deleted database row, or a user permission change mid-session — reset() is a placebo. The actual fix: combine reset() with useEffect to detect whether the error condition has changed, or redirect the user to a safe state. Keep a retry counter in your error boundary. If reset() is called three times with zero success, stop trying and show a full fallback UI with navigation options.
// io.thecodeforge — javascript tutorial 'use client' import { useState } from 'react' export default function SafeError({ error, reset }) { const [attempts, setAttempts] = useState(0) function handleReset() { if (attempts >= 2) return setAttempts(a => a + 1) reset() } return ( <div> <h2>Something broke</h2> <p>{error.message}</p> {attempts < 2 ? ( <button onClick={handleReset}>Try again ({2 - attempts} left)</button> ) : ( <a href="/">Go home</a> )} </div> ) }
reset() with a log statement that records error+attempts to your observability tool. If you see repeated reset calls, you're masking a deeper problem. Fix the root cause, not the symptom.Fixing Syntax Errors: The Hidden Danger in JSX Template Literals
Next.js developers often hit `SyntaxError: expected expression, got 'string' or SyntaxError: string literal contains an unescaped line break when using template literals inside JSX. The root cause: JSX already expects JavaScript expressions inside {}, so adding backticks creates nested string literals that the parser misreads. For example, className={bg-${color}} looks correct but can break if you accidentally use single quotes inside the template literal. Why this happens: JSX's parser treats the backtick as an expression boundary, then finds an unexpected string. The fix is to always use backticks directly inside the curly braces without additional quotes. For multiline strings, avoid raw line breaks—use ` or string concatenation. This mistake spikes in Next.js 15+ because of stricter error boundaries that expose parser failures that were silently swallowed before. Your build fails, not just runtime. Always check for nested quotes in JSX expressions before blaming SSR hydration.
// io.thecodeforge — javascript tutorial // ❌ SyntaxError: unescaped line break const Bad = () => ( <div className={`bg- red`} /> ); // ✅ Fix: inline template with \n const Good = () => ( <div className={`bg-${color}`} /> ); // ❌ Nested quotes break parser const AlsoBad = () => ( <p>{`Hello '${name}'`}</p> ); // ✅ Correct: backticks only const AlsoGood = () => ( <p>{`Hello ${name}`}</p> );
Working Through the Logic: Why Your Server Action Returns Undefined
The most common logic error in Next.js server actions: forgetting that 'use server' functions run in a separate context and cannot directly mutate closure variables. Developers write let result; await and get action(); return result;undefined. Why this happens: server actions are serialized and executed on the server, so the result variable in your client component never receives the value. The fix is to always return data from the action and await it on the client. Another trap: conditional logic inside server actions may short-circuit due to missing await on database calls. Next.js 16's streaming Suspense boundaries amplify this—you get partial renders with null where you expected data. Working through the logic means tracing the data flow: client → server action → return → client state update. Use useActionState or manual startTransition to sync. Never rely on side effects inside actions to populate variables outside them.
// io.thecodeforge — javascript tutorial 'use server'; export async function fetchUser(id) { const user = await db.user.findUnique({ where: { id } }); return user; // ✅ Always return } // In client component: // ❌ Wrong: mutating closure let user; const res = await fetchUser(id); user = res; // 'user' still undefined // ✅ Correct: return and use result const user = await fetchUser(id);
return silently fail—no error thrown, just undefined in your components. Always log the return value during development.fetch() Caching Change Caused 3x API Costs and +400ms Latency After Upgrade
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.- 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.
Date.now(), Math.random()grep -rn 'fetch(' app/ lib/ --include='*.ts' --include='*.tsx' | grep -v 'revalidate\|cache' | head -20curl -s -o /dev/null -w '%{http_code} %{time_total}s' https://your-api.com/datagrep -rn 'import.*from.*client\|"use client"' app/ --include='*.tsx' | head -20grep -rn 'function\|=>\|Date.now\|Math.random|new Date' app/ --include='*.tsx' | grep -v 'use client' | head -20grep -rn "from 'next/router'" app/ --include='*.tsx' --include='*.ts' | head -10grep -rn "from 'next/navigation'" app/ --include='*.tsx' --include='*.ts' | head -10cat next.config.ts | grep -A 10 'images'ls -la public/images/ | head -10| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io.thecodeforge.nextjs16.fetch-cache.ts | async function getProducts() { | Mistake 1 |
| io.thecodeforge.nextjs16.serialization.tsx | export default async function ProductsPage() { | Mistake 2 |
| io.thecodeforge.nextjs16.server-actions.ts | 'use server' // ERROR: not the first statement — directive is ignored | Mistake 3 |
| io.thecodeforge.nextjs16.router-migration.tsx | 'use client' | Mistake 4 |
| io.thecodeforge.nextjs16.layout-effects.tsx | 'use client' | Mistake 5 |
| io.thecodeforge.nextjs16.browser-apis.tsx | export default function HomePage() { | Mistake 6 |
| io.thecodeforge.nextjs16.image-usage.tsx | export function ProductCard() { | Mistake 7 |
| io.thecodeforge.nextjs16.middleware.ts | export function middleware(request: NextRequest) { | Mistake 8 |
| io.thecodeforge.nextjs16.streaming.tsx | export default async function DashboardPage() { | Mistake 9 |
| io.thecodeforge.nextjs16.params-promise.tsx | export default function ProductPage({ | Mistake 10 |
| io.thecodeforge.nextjs16.error-handling.tsx | export default async function DashboardPage() { | Mistake 11 |
| NestedErrorBoundary.js | 'use client' | Error Boundaries Are Not Optional |
| ResetWithRetryLimit.js | 'use client' | reset() Is Not a Magic Wand |
| FixSyntaxError.jsx | const Bad = () => ( | Fixing Syntax Errors |
| FixServerAction.js | 'use server'; | Working Through the Logic |
Key takeaways
Common mistakes to avoid
11 patternsfetch() without explicit cache directive
Passing functions from Server to Client Components
Importing useRouter from 'next/router' in App Router
useEffect in layout with empty dependency array for page tracking
Accessing window or localStorage in Server Components
next/image without width/height or fill prop
Middleware without matcher configuration
Missing error.tsx in route segments
No Suspense boundaries — entire page blocks on slowest fetch
Placing 'use server' after imports or comments
params and searchParams treated as synchronous objects
Interview Questions on This Topic
What changed about fetch() caching in Next.js 15/16, and how do you handle it?
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.What data can you pass from a Server Component to a Client Component, and what cannot?
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.Why does useEffect in a layout only fire once, and how do you handle page tracking?
How do Suspense boundaries affect page loading performance in Next.js 16?
What is the difference between error.tsx, global-error.tsx, and not-found.tsx in App Router?
Frequently Asked Questions
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.
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).
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.
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.
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.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's React.js. Mark it forged?
7 min read · try the examples if you haven't