Mid-level 7 min · April 11, 2026

Next.js 16 — 47 Fetch Calls Uncached, Doubling Server Costs

Upgrading to Next.js 16? CPU spiked 85%, cache hits dropped to 15% due to changed fetch defaults.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Next.js 16 ships with React 19, Partial Prerendering (stable), and Turbopack as default bundler
  • Partial Prerendering combines static shell with dynamic streaming in a single response
  • React 19 brings use() hook, enhanced Server Actions, and React Compiler (experimental)
  • Fetch caching default changed in v15 (not v16) — from force-cache to no-store, explicit cache required in v16
  • Turbopack replaces Webpack as the default dev and build bundler
  • Biggest mistake: upgrading from v14 to v16 without auditing fetch — every request hits origin
✦ Definition~90s read
What is Next.js 16?

Next.js is a React framework by Vercel for production-grade web applications, offering server-side rendering, static generation, and API routes. Version 16, released in early 2025, introduces React 19 integration, Partial Prerendering (PPR), and Turbopack as the default bundler—but its most controversial change is defaulting fetch calls to uncached, which can double server costs for data-heavy pages.

Next.js 16 is like upgrading from a regular car to an electric one.

This shift aims to improve freshness for dynamic content but forces developers to explicitly opt into caching, a reversal from Next.js 14's aggressive caching defaults. If you're building a content-heavy site with static data, you'd typically use Next.js for its hybrid rendering; for real-time apps, consider Remix or a custom Node.js server.

The uncached fetch default means every page load triggers a new request to your database or API, so a page with 47 fetch calls (common in dashboards or e-commerce) can spike costs—Vercel's edge functions charge per invocation, making this a budget concern. Alternatives like Astro or Gatsby might suit purely static sites, but Next.js 16's PPR and React 19 features (like Server Components and Actions) still make it the go-to for complex, interactive UIs where you can tune caching per route.

Plain-English First

Next.js 16 is like upgrading from a regular car to an electric one. The roads are the same (React), but the engine (Turbopack), the battery (Partial Prerendering), and the dashboard (caching) are completely new. Some habits from the old car (like automatic fetch caching) no longer work — you need to learn the new controls before driving.

Next.js 16 is a major release that changes how applications are built, cached, and rendered. It ships with React 19 as the minimum version, Turbopack as the default bundler, and Partial Prerendering as a stable rendering strategy.

These changes are not incremental — they alter fundamental defaults that existing applications depend on. Fetch caching behavior changed. The bundler changed. The rendering model expanded. Applications upgrading from Next.js 14 or 15 without understanding these shifts will encounter broken caching, slower builds, and unexpected rendering behavior.

Tested on Next.js 16.0.0-canary, React 19.0.0.

Why Next.js 16 Defaults to Uncached Fetch — And Why It Hurts

Next.js 16 changes the default caching behavior of fetch() from 'force-cache' to 'no-store'. This means every fetch call in a server component or route handler makes a fresh HTTP request on every render, bypassing the built-in data cache entirely. The core mechanic is simple: unless you explicitly opt in with cache: 'force-cache' or use a revalidation strategy, each request hits the origin server every time.

In practice, this flips the mental model. Previously, developers could rely on automatic deduplication and caching across requests. Now, every page load, every navigation, every re-render triggers a new fetch. For a typical page with 47 fetch calls, that's 47 uncached requests per visit. If your page gets 10,000 visits per day, that's 470,000 extra origin requests daily — directly doubling server costs if your API charges per request or your database can't handle the load.

Use this default when you are building real-time dashboards, live data feeds, or any system where stale data is unacceptable. But for most content-driven sites — blogs, e-commerce, marketing pages — you must explicitly add caching or revalidation. The default is optimized for correctness, not cost or performance. Ignoring it in production is a fast track to a surprise cloud bill.

Default Is Not Safe for Production
Next.js 16's fetch default is 'no-store' — every request is uncached. If you migrate without auditing fetch calls, expect a sudden cost spike.
Production Insight
A SaaS dashboard with 47 API calls per page saw AWS bills jump from $800 to $1,600/month after upgrading to Next.js 16.
Symptom: Database connection pool exhaustion and 5xx errors under normal traffic, because every fetch hit the DB directly.
Rule: Always set a global fetch cache policy in next.config.js or wrap fetch with a default cache: 'force-cache' for any non-real-time data.
Key Takeaway
Next.js 16 defaults fetch to 'no-store' — every request is uncached unless you opt in.
Without explicit caching, 47 fetch calls per page means 47 origin hits per visit, doubling server costs.
Audit all fetch calls on upgrade; add cache: 'force-cache' or revalidation for content that doesn't change per request.

React 19 Integration

Next.js 16 requires React 19 as the minimum version. This is not a peer dependency bump — React 19 changes how components render, how data flows, and how the compiler optimizes code.

The three most impactful React 19 features in Next.js 16 are enhanced Server Actions (stable since Next.js 14, with useActionState and improved Form handling in React 19), the use() hook for reading promises and context in conditional logic, and the React Compiler (experimental integration) that eliminates the need for useMemo and useCallback in most cases.

Server Actions replace API route handlers for form submissions and mutations. The use() hook replaces useEffect-based data fetching patterns for Server Component data. The React Compiler automatically memoizes components, removing a major source of performance bugs.

io.thecodeforge.nextjs16.react19.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// ============================================
// React 19 Features in Next.js 16
// ============================================

// ---- 1. Server Actions ----
// Server Actions run on the server, called directly from components

'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createOrder(formData: FormData) {
  const productId = formData.get('productId') as string
  const quantity = parseInt(formData.get('quantity') as string, 10)

  if (!productId || quantity < 1) {
    return { error: 'Invalid input' }
  }

  try {
    await fetch('https://api.internal.io/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ productId, quantity }),
    })

    revalidatePath('/orders')
    redirect('/orders/confirmation')
  } catch (err) {
    return { error: 'Order creation failed' }
  }
}

// ---- 2. use() Hook ----
// Read promises and context conditionally (unlike useContext)

import { use, Suspense } from 'react'

interface Product {
  id: string
  name: string
  price: number
}

async function fetchProduct(id: string): Promise<Product> {
  const res = await fetch(
    `https://api.internal.io/products/${id}`,
    { next: { revalidate: 60 } }
  )
  if (!res.ok) throw new Error('Product not found')
  return res.json()
}

function ProductCard({ productPromise }: {
  productPromise: Promise<Product>
}) {
  // use() unwraps the promise — can be called conditionally
  const product = use(productPromise)

  return (
    <div>
      <h2>{product.name}</h2>
      <p>${product.price.toFixed(2)}</p>
    </div>
  )
}

export default function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const productPromise = fetchProduct(params.id)

  return (
    <Suspense fallback={<div>Loading product...</div>}>
      <ProductCard productPromise={productPromise} />
    </Suspense>
  )
}

// ---- 3. Form Component with Server Actions ----
// React 19 Form component handles pending states automatically

import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Placing order...' : 'Place order'}
    </button>
  )
}

export function OrderForm({ productId }: { productId: string }) {
  return (
    <form action={createOrder}>
      <input type="hidden" name="productId" value={productId} />
      <label>
        Quantity:
        <input
          type="number"
          name="quantity"
          defaultValue={1}
          min={1}
          max={100}
        />
      </label>
      <SubmitButton />
    </form>
  )
}
React 19 as a Server-First Framework
  • Server Actions eliminate API route handlers for mutations — direct server functions from components
  • use() hook replaces useEffect for data fetching — promises unwrap inside components
  • React Compiler auto-memoizes — useMemo and useCallback become unnecessary in most cases
  • Form component handles pending states — no manual loading state management
  • useActionState replaces useReducer for form state with built-in error handling
Production Insight
React Compiler (experimental) eliminates manual memoization — but it cannot memoize everything.
Components with side effects or external state still need explicit optimization.
Rule — do not pre-optimize with useMemo.
Key Takeaway
React 19 makes server-first patterns the default in Next.js 16.
Server Actions (enhanced in React 19) replace API routes for mutations — simpler and type-safe.
The React Compiler (experimental) removes the need for manual useMemo and useCallback.

Partial Prerendering (PPR)

Partial Prerendering is the most significant rendering innovation in Next.js 16. It combines static and dynamic rendering in a single response — the static shell is served instantly from the edge, while dynamic content streams: let the compiler work, then profile in via React Suspense.

PPR solves the fundamental tension between static performance and dynamic content. Traditional static generation serves fast pages but cannot show personalized data. Traditional server-side rendering shows personalized data but adds server latency to every request. PPR does both — the static parts render at build time, the dynamic parts render at request time.

The implementation requires wrapping dynamic content in Suspense boundaries. Everything outside Suspense is pre-rendered statically. Everything inside Suspense streams dynamically. Next.js automatically splits the response into static and dynamic chunks.

io.thecodeforge.nextjs16.ppr.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// ============================================
// Partial Prerendering in Next.js 16
// ============================================

// ---- next.config.ts ----
// Enable PPR for the entire application
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

export default nextConfig

// ---- Product Page with PPR ----
// Static shell renders at build time
// Dynamic sections stream at request time
import { Suspense } from 'react'

// Static component — rendered at build time, served from edge
function ProductHeader({ name, description }: {
  name: string
  description: string
}) {
  return (
    <header>
      <h1>{name}</h1>
      <p>{description}</p>
    </header>
  )
}

// Static component — rendered at build time
function ProductImages({ images }: { images: string[] }) {
  return (
    <div className="product-images">
      {images.map((src, i) => (
        <img key={i} src={src} alt={`Product image ${i + 1}`} />
      ))}
    </div>
  )
}

// Dynamic component — rendered at request time
async function ProductPrice({ productId }: { productId: string }) {
  // This fetch runs at request time — price may change
  const res = await fetch(
    `https://api.internal.io/products/${productId}/price`,
    { cache: 'no-store' }
  )
  const { price, currency, inStock } = await res.json()

  return (
    <div className="product-price">
      <span className="price">{currency} {price.toFixed(2)}</span>
      <span className={inStock ? 'in-stock' : 'out-of-stock'}>
        {inStock ? 'In Stock' : 'Out of Stock'}
      </span>
    </div>
  )
}

// Dynamic component — rendered at request time
async function PersonalizedRecommendations({
  userId
}: {
  userId: string
}) {
  const res = await fetch(
    `https://api.internal.io/recommendations/${userId}`,
    { cache: 'no-store' }
  )
  const recommendations = await res.json()

  return (
    <div className="recommendations">
      <h3>Recommended for you</h3>
      {recommendations.map((rec: any) => (
        <div key={rec.id}>{rec.name}</div>
      ))}
    </div>
  )
}

// Dynamic component — rendered at request time
async function AddToCartForm({ productId }: { productId: string }) {
  return (
    <form action={addToCart}>
      <input type="hidden" name="productId" value={productId} />
      <button type="submit">Add to Cart</button>
    </form>
  )
}

// Page component — PPR splits static and dynamic automatically
export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  // This fetch can be cached — product data rarely changes
  const product = await fetch(
    `https://api.internal.io/products/${params.id}`,
    { next: { revalidate: 3600 } }
  ).then(r => r.json())

  return (
    <main>
      {/* STATIC: rendered at build time */}
      <ProductHeader
        name={product.name}
        description={product.description}
      />
      <ProductImages images={product.images} />

      {/* DYNAMIC: streamed at request time */}
      <Suspense fallback={<div>Loading price...</div>}>
        <ProductPrice productId={params.id} />
      </Suspense>

      <Suspense fallback={<div>Loading cart...</div>}>
        <AddToCartForm productId={params.id} />
      </Suspense>

      <Suspense fallback={<div>Loading recommendations...</div>}>
        <PersonalizedRecommendations userId="demo-user" />
      </Suspense>
    </main>
  )
}
PPR as Static Shell + Dynamic Streaming
  • Everything outside Suspense is pre-rendered statically at build time
  • Everything inside Suspense is rendered dynamically at request time
  • The user sees the static shell immediately — no blank page waiting for server
  • Dynamic content streams in as each Suspense boundary resolves
  • Edge CDN serves the static shell — sub-100ms TTFB globally
Production Insight
PPR requires careful Suspense boundary placement — too few boundaries means slow streaming.
Each Suspense boundary is an independent streaming chunk.
Rule: wrap every dynamic section in its own Suspense boundary for optimal streaming.
Key Takeaway
PPR combines static generation and dynamic rendering in a single response.
Static shell serves from the edge — dynamic content streams via Suspense.
Suspense boundary placement determines streaming granularity and perceived performance.

Turbopack as Default Bundler

Turbopack replaces Webpack as the default bundler in Next.js 16 for both development and production builds. This is not an experimental flag — Turbopack is the default, and Webpack is the fallback.

Turbopack is written in Rust and provides significant performance improvements over Webpack. Development server cold starts are 5-10x faster. Incremental rebuilds are near-instant. Production builds are faster and produce smaller bundles due to more aggressive tree-shaking.

The migration from Webpack to Turbopack is mostly transparent for standard Next.js applications. Custom Webpack configurations in next.config.js need to be migrated to Turbopack equivalents. Some Webpack loaders and plugins are not compatible and need replacement.

io.thecodeforge.nextjs16.turbopack.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// ============================================
// Turbopack Configuration in Next.js 16
// ============================================

// ---- next.config.ts ----
// Turbopack is the default — no flag needed
// Webpack fallback available with --no-turbopack flag

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // Turbopack configuration (replaces webpack config)
  turbopack: {
    // Resolve aliases (replaces webpack resolve.alias)
    resolveAlias: {
      '@components': './src/components',
      '@utils': './src/utils',
      '@hooks': './src/hooks',
    },

    // Module rules (replaces webpack module.rules)
    rules: {
      '*.svg': {
        test: /\.svg$/,
        use: ['@svgr/webpack'],
        as: '*.js',
      },
    },
  },

  // Standard Next.js config works with Turbopack
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
      },
    ],
  },

  // Transpile packages (works with both Turbopack and Webpack)
  transpilePackages: ['@acme/ui', '@acme/utils'],
}

export default nextConfig

// ---- Webpack to Turbopack Migration ----
// Common patterns that need migration
// BEFORE (Webpack):
// module.exports = {
//   webpack: (config, { buildId, dev, isServer }) => {
//     config.resolve.alias['@'] = path.resolve(__dirname, 'src')
//     config.module.rules.push({
//       test: /\.svg$/,
//       use: ['@svgr/webpack'],
//     })
//     return config
//   }
// }

// AFTER (Turbopack): Use turbopack config in next.config.ts
// No webpack callback — use declarative turbopack config above

// ---- Turbopack-Compatible Alternatives ----

// Webpack loader -> Turbopack equivalent
// css-loader -> Built-in (no config needed)
// file-loader -> Built-in (use ?url suffix)
// raw-loader -> Built-in (use ?raw suffix)
// @svgr/webpack -> turbopack.rules with as: '*.js'
// babel-loader -> Built-in (SWC handles transforms)
// ts-loader -> Built-in (SWC handles TypeScript)

// ---- Build Commands ----
// Development (Turbopack default):
//   npx next dev
//
// Development (Webpack fallback):
//   npx next dev --no-turbopack
//
// Production build (Turbopack default):
//   npx next build
//
// Production build (Webpack fallback):
//   npx next build --no-turbopack
//
// Debug build with Turbopack stats:
//   npx next build --debug
Turbopack Migration Pitfalls
  • Custom webpack() callback in next.config.js is ignored — migrate to turbopack config
  • Some Webpack loaders are incompatible — check Turbopack compatibility before upgrading
  • webpack-bundle-analyzer does not work with Turbopack — use next build --debug instead
  • Module Federation is not supported in Turbopack — use Webpack fallback if needed
  • Custom PostCSS config works but custom Webpack PostCSS loader does not
Production Insight
Turbopack produces smaller production bundles than Webpack in most cases.
But some Webpack optimizations (Module Federation, custom plugins) are not available.
Rule: test production bundle size comparison before committing to Turbopack.
Key Takeaway
Turbopack is the default bundler in Next.js 16 — Webpack is the fallback.
Development builds are 5-10x faster — production bundles are typically smaller.
Custom webpack() configs must be migrated to the declarative turbopack config.

Caching Changes

Next.js 15 changed the default caching behavior for fetch() requests — a breaking change that causes most incidents during Next.js 16 upgrades. In Next.js 14 and earlier, fetch was cached by default (force-cache). Starting in Next.js 15, fetch uses no-store by default — every request hits the origin server unless you explicitly opt into caching.

Next.js 16 does not change the default again, but it enforces the explicit caching model and adds new APIs: the 'use cache' directive and cacheLife profiles for fine-grained, component-level control. Combined with Turbopack as the default bundler, a direct upgrade from v14 to v16 surfaces uncached fetches immediately as origin load spikes.

This remains the #1 upgrade risk because applications built on v14 relied on implicit caching. The v15+ model is predictable: you must choose force-cache, revalidate: N, or no-store for every fetch.

io.thecodeforge.nextjs16.caching.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// ============================================
// Next.js 15+ Caching (Critical for v16 Upgrades)
// ============================================

// ---- BEFORE (Next.js 14 and earlier) ----
// fetch was cached by default
// const data = await fetch('https://api.example.com/products')
// Implicitly: { cache: 'force-cache' }

// ---- AFTER (Next.js 15 and 16) ----
// fetch uses no-store by default
// const data = await fetch('https://api.example.com/products')
// Now: { cache: 'no-store' } — hits origin every time

// ---- Explicit Caching Patterns (Required) ----

// 1. Permanent cache (force-cache)
// Use for: static data that never changes
const countries = await fetch('https://api.example.com/countries', {
  cache: 'force-cache',
})

// 2. Time-based cache (revalidate)
// Use for: data that changes periodically
const products = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 }, // Cache for 1 hour
})

// 3. Tag-based cache (revalidateTag)
// Use for: data invalidated by events
const order = await fetch(`https://api.example.com/orders/${orderId}`, {
  next: { tags: ['orders', `order-${orderId}`] },
})

import { revalidateTag } from 'next/cache'

export async function updateOrder(orderId: string, data: any) {
  await fetch(`https://api.example.com/orders/${orderId}`, {
    method: 'PUT',
    body: JSON.stringify(data),
  })
  revalidateTag(`order-${orderId}`)
  revalidateTag('orders')
}

// 4. No cache (no-store)
// Use for: real-time data that must be fresh
const stockPrice = await fetch('https://api.example.com/stock/AAPL', {
  cache: 'no-store',
})

// ---- New in Next.js 16: cacheLife API ----
// Define reusable profiles in next.config.ts:
// experimental: {
//   cacheLife: {
//     static: { stale: Infinity },
//     frequent: { stale: 60, revalidate: 10 },
//     standard: { stale: 300, revalidate: 60 },
//   }
// }

import { cacheLife } from 'next/cache'

export async function ProductList() {
  'use cache'
  cacheLife('standard')

  const products = await fetch('https://api.example.com/products')
  return products.json()
}
Critical: Fetch Caching Changed in v15, Not v16
  • Next.js 15+: fetch defaults to no-store — every fetch hits origin unless configured
  • Next.js 14 and earlier: fetch defaulted to force-cache — implicit caching
  • Upgrading 14→16 skips the v15 breaking change notice — audit fetches first
  • Use grep to find all fetch calls without cache options before upgrading
  • Next.js 16 adds 'use cache' and cacheLife — use them for component-level control
Production Insight
The v15 caching change is the #1 cause of 14→16 upgrade failures.
A 50-fetch app on v14 becomes 50 uncached origin requests in v16.
Rule: run the fetch audit on v14, add explicit caching, then upgrade to v16.
Key Takeaway
Next.js 15 changed fetch from force-cache to no-store — Next.js 16 keeps this default.
Every fetch in v16 must have explicit cache configuration.
Use 'use cache' and cacheLife in Next.js 16 for fine-grained component caching.

Improved Image and Font Handling

Next.js 16 improves the next/image component with automatic AVIF/WebP format selection, responsive image generation, and blur-up placeholders. The next/font module now supports variable fonts natively and provides zero-layout-shift font loading.

These improvements reduce Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) — two Core Web Vitals metrics that directly impact SEO rankings. The image component now generates srcset and sizes attributes automatically based on the image container width.

io.thecodeforge.nextjs16.images_fonts.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// ============================================
// Next.js 16 Image and Font Improvements
// ============================================

// ---- Optimized Image Component ----
import Image from 'next/image'

export function ProductGallery({ images }: { images: string[] }) {
  return (
    <div className="gallery">
      {images.map((src, index) => (
        <Image
          key={index}
          src={src}
          alt={`Product image ${index + 1}`}
          width={800}
          height={600}
          // Automatic format selection (AVIF > WebP > original)
          priority={index === 0} // First image loads eagerly
          placeholder="blur"
          blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
          sizes="(max-width: 1200px) 50vw, 33vw"
          className="rounded-lg"
        />
      ))}
    </div>
  )
}

// ---- Remote Image with Loader ----
export function RemoteProductImage({
  src,
  alt
}: {
  src: string
  alt: string
}) {
  return (
    <Image
      src={src}
      alt={alt}
      width={400}
      height={300}
      loader={({ src, width, quality }) => {
        return `https://cdn.example.com${src}?w=${width}&q=${quality || 75}`
      }}
      sizes="(max-width: 768px) 100vw, 400px"
    />
  )
}

// ---- next/font with Variable Fonts ----
import { Inter, JetBrains_Mono } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  // Variable font with specific axes
  axes: ['slnt'],
})

const jetbrains = JetBrains_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-mono',
})

// ---- Layout with Font Variables ----
export default function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrains.variable}`}
    >
      <body className={inter.className}>
        {children}
      </body>
    </html>
  )
}

// ---- Using Font Variables in CSS ----
// app/globals.css:
// :root {
//   --font-sans: var(--font-inter);
//   --font-mono: var(--font-mono);
// }
//
// body {
//   font-family: var(--font-sans);
// }
//
// code, pre {
//   font-family: var(--font-mono);
// }
Image and Font Performance Tips
  • Set priority={true} on above-the-fold images — they load eagerly with high fetch priority
  • Use sizes attribute to prevent loading oversized images on mobile
  • next/font eliminates layout shift — fonts self-host with automatic subsetting
  • Variable fonts reduce HTTP requests — one file covers all weights and styles
  • Blur placeholder provides instant visual feedback while the image loads
Production Insight
Missing sizes attribute causes the browser to download the largest image variant on every device.
A 2400px image downloaded on a 375px mobile screen wastes bandwidth and slows LCP.
Rule: always set sizes based on your CSS layout breakpoints.
Key Takeaway
next/image generates responsive srcset automatically based on container width.
next/font self-hosts Google Fonts with zero layout shift and automatic subsetting.
Both components directly improve Core Web Vitals scores for SEO.

Server Actions After Dark: Why Your RPC Layer Just Became an Attacker’s Playground

Server Actions in Next.js 16 aren't new — they're mandatory if you want to mutate data without a client-side waterfall. But here's what the hype pieces won't tell you: they ship as regular POST endpoints, and you've just handed every frontend user a direct line to your database mutation logic.

The framework will generate the endpoint for you. That's the clever bit. The dangerous bit is that every form action, every button that calls useTransition with a server action — it's now a public API. If you're wrapping db.users.delete() inside a Server Action without checking the session, congratulations — you've just built an admin panel for the whole internet.

Rate limiting, CSRF tokens, and server-side validation aren't optional anymore. They're the difference between 'ship fast' and 'ship a lawsuit'. Next.js 16 auto-generates the form data parsing, but it won't protect you from yourself. Treat every Server Action like a raw Express route with no middleware.

ServerActionGuard.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — javascript tutorial

import { validateSession, rateLimitAction } from '@/lib/auth'

export async function deleteUserAction(formData: FormData) {
  const session = await validateSession()
  if (!session?.user?.id) {
    throw new Error('Unauthorized: no session found')
  }

  await rateLimitAction(session.user.id, 'delete', 5) // max 5 deletions/min

  const targetUserId = formData.get('userId')
  if (session.user.role !== 'admin' && targetUserId !== session.user.id) {
    return { error: 'You can only delete your own account' }
  }

  await db.users.delete({ where: { id: targetUserId } })
  
  return { success: true }
}
Output
{ error: 'Unauthorized: no session found' } (if no cookie present)
{ error: 'You can only delete your own account' } (if role check fails)
{ success: true } (if all guards pass)
Production Trap: Exposed Endpoints
Server Actions are just POST routes under /api/actions. Add a middleware that logs every action call with IP and user ID — you'll thank yourself when the pentest report lands.
Key Takeaway
Every Server Action is a public endpoint by default. Authenticate, authorize, and rate limit every single one.

CSS Modules in 16: The `.module.css` File That’s About to Break Your Production Build

CSS Modules worked fine in Next.js 14. In 16, Turbopack is the default bundler, and it handles CSS Modules differently than webpack did. The 'it just works' crowd is about to discover that their global CSS variables don't cascade into .module.css files the same way.

The problem: Turbopack compiles each CSS Module in isolation by default. If you're relying on :root variables defined in a global stylesheet leaking into your scoped module class, they won't resolve at build time. You'll see 'undefined' in production where a color variable should be. Debugging that at 2 AM is not fun.

The fix is boring but necessary: explicitly import your design tokens into every CSS Module that uses them, or use @value for shared constants. Or stop using CSS Modules entirely and switch to Tailwind — but that's a different argument. If you're keeping CSS Modules, run next build with the --debug flag before your staging deploy. Catch the 'undefined' variables before your QA team does.

CSSModuleBreak.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// io.thecodeforge — javascript tutorial

// ❌ BROKEN IN TURBOPACK:
/* globals.css */
:root {
  --brand-primary: #1a73e8;
}

/* Card.module.css */
.card {
  background-color: var(--brand-primary); /* resolves to 'undefined' at build time */
}

// ✅ FIX: explicit import
/* Card.module.css */
@import '../styles/globals.css'; 
.card {
  background-color: var(--brand-primary);
}

// OR use @value for shared tokens
@value primary: #1a73e8;
.card {
  background-color: primary;
}
Output
Production CSS output (broken): .card { background-color: ; }
Production CSS output (fixed): .card { background-color: #1a73e8; }
Senior Shortcut: Turbopack Debug
Add export const dynamic = 'force-static' to a layout that uses CSS Modules. If the build output shows empty variable values, you've got the isolation bug. Switch to explicit imports.
Key Takeaway
Turbopack isolates CSS Modules. Don't rely on global :root variables leaking in. Import them explicitly or use @value.

Before You Even Think About `npx create-next-app`: What’s Running on Your Machine

Everybody wants to skip to the npm run dev dopamine hit. But Next.js 16 ships with Turbopack, React 19, and a stricter Node.js baseline. Forget the right Node version and your first build will fail before a single line of your code runs — but only in CI, not locally, because your laptop happens to run Node 22 while Docker runs 18.

Minimum required: Node.js 18.17 or higher. That’s non-negotiable. Turbopack needs 18.17+ for native fetch and WebSocket improvements. React 19 also drops support for older Node runtimes. If you’re still on Node 16, you’re not upgrading to Next.js 16. Period.

Also: You need pnpm 8+, yarn 4+, or npm 10+. The new dependency resolution chokes on older lockfile formats. And don’t npx create-next-app inside an existing project unless you like debugging port conflicts and rogue .next folders. Fresh directory or die.

I’ve seen three teams waste a day because someone had Node 16.10 installed via nvm and forgot to switch. Use .nvmrc from day one.

version-check.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — javascript tutorial

const requiredNode = 18.17;
const nodeVersion = parseFloat(process.version.slice(1));

if (nodeVersion < requiredNode) {
  console.error(
    `❌ Node ${process.version} detected. Next.js 16 requires ≥ ${requiredNode}.`
  );
  process.exit(1);
}

console.log(`✅ Node ${process.version} — good to go.`);
Output
✅ Node v20.11.1 — good to go.
Production Trap:
Your CI runner likely uses the default Node version from the base image — often 16.x or 18.16. Always pin node-version: 20 in your GitHub Actions or Dockerfile. This bug hits staging, not local.
Key Takeaway
Check node --version before you code. Save yourself a Slack apology.

Local Dev Loop: Kill the Refresh Dance Before It Kills Your Flow

Next.js 16’s Turbopack dev server is fast — but only if you treat it right. Stop running next dev from your monorepo root without a turbo.json pipeline. That’s how your incremental builds turn into full recompiles. Set experimental.turbo explicitly in next.config.js or the optimizer makes pessimistic assumptions about your routes.

Second: you’re not using next dev --turbo? You’re running Webpack 5 on a framework that defaulted to Turbopack. Your dev server will be slower than your colleagues’. That’s not Next.js slowing you down — that’s you refusing to read release notes.

Third: When debugging Server Actions locally, use NODE_ENV=development next dev. Without it, error stacks get minified. Yes, even in dev mode. Turbopack does not auto-enable readable traces. I wasted two hours on a 500 that was a null reference in a server action — took me seconds in prod because the stack was mangled in dev.

Stop manually killing node_modules/.cache. Next.js 16 does partial invalidation now. Let it breathe.

dev-setup.shJAVASCRIPT
1
2
3
4
5
6
7
8
9
// io.thecodeforge — javascript tutorial

#!/bin/bash
# Use this — not raw `npx next dev`

echo "Starting Next.js 16 dev with turbo & readable traces"

export NODE_ENV=development
npx next dev --turbo --port 3000 --hostname 0.0.0.0
Output
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
▲ Next.js 16.0.0 (Turbopack)
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000
> Turbopack mode enabled — hot module replacements in <50ms
Senior Shortcut:
Add "dev": "NODE_ENV=development next dev --turbo" to your package.json scripts. Then alias nd to npm run dev in your shell. Muscle memory beats turbopack flag I promise you.
Key Takeaway
Run next dev --turbo with NODE_ENV=development or your error traces will lie to you.
● Production incidentPOST-MORTEMseverity: high

Next.js 14 to 16 Upgrade Broke API Caching and Doubled Server Costs

Symptom
After deploying Next.js 16, the product listing page load time increased from 400ms to 1.2 seconds. Origin server CPU usage spiked from 30% to 85%. CDN cache hit ratio dropped from 92% to 15%. Monthly API costs doubled within one week.
Assumption
The team blamed Turbopack — assuming the new default bundler in Next.js 16 introduced a production performance regression.
Root cause
Next.js 15 changed fetch() default caching from force-cache to no-store in October 2024. The team was on Next.js 14, where 47 fetch calls relied on implicit caching. They upgraded directly to Next.js 16, skipping the v15 release notes. With no explicit cache options, all 47 fetches became uncached in production. Next.js 16 didn't introduce the change — it exposed the v15 breaking change during the Turbopack-enabled build.
Fix
Audited all 47 fetch calls and classified by cache requirement. Added cache: 'force-cache' to 32 fetches for static reference data. Added next: { revalidate: 3600 } to 12 fetches for product catalog data. Kept cache: 'no-store' on 3 fetches for real-time inventory. Added 'use cache' directive to 4 expensive Server Components. Response times dropped to 320ms — faster than v14 due to Turbopack's smaller bundle.
Key lesson
  • The fetch caching default changed in Next.js 15, not 16 — a 14→16 jump inherits this breaking change
  • Implicit caching hides performance dependencies — always use explicit cache options
  • Test CDN cache hit ratios in staging before any major version jump
  • Turbopack in Next.js 16 actually improved performance once caching was fixed
Production debug guideCommon symptoms after upgrading to Next.js 165 entries
Symptom · 01
API response times increased 2-3x after upgrade
Fix
Check fetch caching defaults. Next.js 15+ uses no-store by default (changed from v14). Add explicit cache: 'force-cache' or revalidate: N to fetches that should be cached.
Symptom · 02
Build fails with Turbopack errors
Fix
Run next build --no-turbopack to fall back to Webpack temporarily. Check for unsupported Webpack loaders or plugins. Migrate to Turbopack-compatible alternatives.
Symptom · 03
Server Components render differently than before
Fix
Check for client component boundaries. React 19 changed how Server Components handle Suspense. Verify that 'use client' directives are on the correct components.
Symptom · 04
Partial Prerendering not working — page loads entirely dynamically
Fix
Verify that dynamic content is wrapped in a Suspense boundary. Static shell must be outside Suspense. Check that the route uses the experimental_ppr flag or PPR is enabled in next.config.
Symptom · 05
TypeScript errors after upgrade
Fix
Update @types/react to version 19. Update next-env.d.ts. Run npx next lint --fix. Check for deprecated type imports from next/app.
★ Next.js 16 Quick Debug ReferenceFast commands for diagnosing Next.js 16 issues
Fetch responses not cached
Immediate action
Audit fetch cache behavior
Commands
grep -rn 'fetch(' app/ --include='*.tsx' --include='*.ts' | grep -v 'cache'
Add cache: 'force-cache' or revalidate: N to each fetch
Fix now
Every fetch without explicit cache option uses no-store by default in Next.js 15+ (enforced in 16)
Turbopack build failing+
Immediate action
Fall back to Webpack and identify incompatibilities
Commands
npx next build --no-turbopack
npx @next/codemod@latest turbo-migrate
Fix now
Run the codemod to identify and fix Turbopack incompatibilities
React 19 hydration errors+
Immediate action
Check for mismatched Server and Client component boundaries
Commands
npx next lint --fix
grep -rn 'useState\|useEffect' app/ --include='*.tsx' | grep -v 'use client'
Fix now
Components using hooks must have 'use client' directive at the top of the file
Bundle size increased after upgrade+
Immediate action
Analyze bundle with Turbopack stats
Commands
npx next build --debug
ANALYZE=true npx next build
Fix now
Turbopack typically produces smaller bundles — check for accidental client-side imports
Next.js 15 vs Next.js 16 Key Changes
FeatureNext.js 15Next.js 16Migration Impact
React VersionReact 18 or 19React 19 requiredUpdate @types/react, fix hydration issues
Default BundlerWebpackTurbopackMigrate webpack config to turbopack config
Fetch Cachingno-store by defaultno-store by defaultChange happened in v15 — audit all fetch calls if coming from v14
Partial PrerenderingExperimentalStableAdd Suspense boundaries around dynamic content
Server ActionsStableEnhanced with useActionStateReplace API routes with Server Actions
'use cache' DirectiveNot availableNew cache APIMark cacheable components with 'use cache'
React CompilerExperimentalExperimental integrationCompiler auto-memoizes — remove unnecessary useMemo/useCallback
Turbopack ConfigN/A (Webpack config)turbopack key in next.configReplace webpack() callback with turbopack object

Key takeaways

1
Next.js 16 requires React 19
upgrade React before upgrading Next.js
2
Next.js 16 enforces no-store default for fetch() (introduced in v15)
audit every fetch call
3
Turbopack replaces Webpack as default
migrate webpack config to turbopack config
4
Partial Prerendering combines static shell with dynamic streaming via Suspense
5
The 'use cache' directive provides component-level caching control
6
React Compiler (experimental) auto-memoizes
remove unnecessary useMemo and useCallback

Common mistakes to avoid

7 patterns
×

Upgrading straight from Next.js 14 to Next.js 16 (skipping 15)

Symptom
Hitting two breaking changes at once: the fetch caching change from v15 PLUS Turbopack and other v16 defaults. Results in origin server overload, build failures, and unexpected rendering.
Fix
Upgrade incrementally (14 → 15 first to fix caching, then to 16) OR perform a full audit for BOTH the v15 caching change and v16 Turbopack migration before deploying.
×

Upgrading to Next.js 16 without auditing fetch cache behavior

Symptom
Origin server CPU spikes to 90%+, page load times increase 3-5x, CDN cache hit ratio drops from 90%+ to under 20%
Fix
grep -rn 'fetch(' app/ --include='.tsx' --include='.ts' | grep -v 'cache'. Add explicit cache: 'force-cache', next: { revalidate: N }, or cache: 'no-store' to every fetch call.
×

Not installing React 19 before upgrading Next.js

Symptom
Build fails with peer dependency errors, or runtime crashes with missing React APIs like use() and useActionState
Fix
npm install react@19 react-dom@19 @types/react@19 @types/react-dom@19. Then upgrade next: npm install next@16.
×

Keeping custom webpack() config when using Turbopack

Symptom
Custom webpack configuration is silently ignored — aliases, loaders, and plugins do not apply, causing build errors or missing imports
Fix
Migrate webpack config to turbopack config in next.config.ts. Use resolveAlias instead of resolve.alias. Use turbopack.rules instead of module.rules.
×

Not wrapping dynamic content in Suspense for Partial Prerendering

Symptom
Entire page renders dynamically — no static shell, no streaming, slower than SSR. PPR provides no benefit without Suspense boundaries.
Fix
Wrap every dynamic section (price, recommendations, user-specific content) in its own Suspense boundary with a fallback UI.
×

Using useMemo and useCallback with React Compiler enabled

Symptom
No functional issue but unnecessary code — the compiler auto-memoizes, making manual memoization redundant. Adds bundle size and cognitive overhead.
Fix
Remove manual useMemo and useCallback where the compiler handles optimization. Keep them only for values with expensive computation that the compiler cannot optimize.
×

Not testing production build after switching to Turbopack

Symptom
Development works fine but production build fails or produces different behavior due to Turbopack's different optimization strategy
Fix
Always run npx next build and test the production output. Compare bundle size with npx next build --no-turbopack if issues arise.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What are the major changes in Next.js 16 compared to Next.js 15?
Q02SENIOR
How does Partial Prerendering work and when should you use it?
Q03SENIOR
Your team upgraded to Next.js 16 and API costs doubled. How do you diagn...
Q04SENIOR
What is the React Compiler and how does it change Next.js development?
Q01 of 04JUNIOR

What are the major changes in Next.js 16 compared to Next.js 15?

ANSWER
Next.js 16 introduces several breaking changes: 1. React 19 is required — React 18 is no longer supported. This brings the use() hook, enhanced Server Actions with useActionState, and React Compiler (experimental). 2. Turbopack is the default bundler — replaces Webpack for both development and production. Custom webpack configs must be migrated to the turbopack config object. 3. Fetch caching default (changed in v15, enforced in v16) — v15 switched fetch from force-cache to no-store. Next.js 16 adds 'use cache' and cacheLife APIs. Every fetch without explicit options hits origin unless configured. 4. Partial Prerendering is stable — combines static shell with dynamic streaming via Suspense boundaries. 5. New 'use cache' directive and cacheLife API — provide fine-grained component-level caching control. The most disruptive change for v14 upgraders is the fetch caching default from v15.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I still use Webpack in Next.js 16?
02
Do I have to use Partial Prerendering in Next.js 16?
03
What happens if I do not change my fetch calls after upgrading?
04
Is the React Compiler enabled by default in Next.js 16?
05
How do I migrate from next.config.js with webpack() to Turbopack?
🔥

That's React.js. Mark it forged?

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

Previous
React 19 New Features
20 / 47 · React.js
Next
Partial Prerendering in Next.js 16 — The Complete Guide