Next.js 16 reversed the framework's oldest default. Caching used to be implicit and hard to reason about; now everything is dynamic until you say otherwise, and the thing you say is use cache. That single inversion changes how you structure pages, what your hosting bill looks like, and how much of an upgrade project you are signing up for.
This is a working guide to Next.js as it stands now: the caching model, the server-first mental model underneath it, deployment and self-hosting, the security and support cadence that most articles skip, and an honest account of where the framework costs you more than it gives.
What Next.js is now
Next.js is a full-stack React framework built and maintained by Vercel. It gives you file-based routing, React Server Components, server-side rendering, static generation, a bundler, an image pipeline, a metadata API and a mutation model - as one integrated system rather than assembled parts.
The App Router has been stable since 13.4 in 2023 and is where all development happens. The Pages Router still works and still receives fixes, but it is the legacy path. Everything below is App Router unless stated otherwise.
| Release | Shipped | What it changed |
|---|---|---|
| 16 | October 2025 | Cache Components, Turbopack as the default bundler, proxy.ts replacing middleware.ts, React Compiler support stable, React 19.2, async params enforced |
| 16.1 | December 2025 | Turbopack filesystem caching for next dev stable, next dev --inspect |
| 16.2 | March 2026 | Roughly 400% faster dev startup and 50% faster rendering by Vercel's measurements, stable Adapter API, Turbopack SRI and Server Fast Refresh |
| 16.3 | Preview as of August 2026 | Instant Navigations, Partial Prefetching, Turbopack memory eviction and persistent build cache |
The mental model: server first
Every component in app/ is a Server Component by default. It runs on the server, can be async, can query a database directly, and ships no JavaScript to the browser. Adding 'use client' at the top of a file marks the boundary where interactivity begins - and everything imported from that file downwards goes into the client bundle with it.
// app/products/[id]/page.tsx - Server Component, no 'use client'
import { db } from '@/lib/db'
import { AddToCart } from './add-to-cart'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params // async since 16 - no synchronous access
const product = await db.product.findUnique({ where: { id } })
if (!product) notFound()
return (
<article>
<h1>{product.name}</h1>
<AddToCart productId={product.id} /> {/* the only client JavaScript */}
</article>
)
}params and searchParams are Promises in Next.js 16. The synchronous compatibility shim from 15 is gone.The discipline that matters: push 'use client' as far down the tree as it will go. A client boundary at the layout level drags your entire page into the browser bundle and quietly undoes the reason you are using Server Components at all.
Cache Components, the headline change
Dynamic by default
Before 16, a page's rendering mode was inferred. Use a dynamic API and the page became dynamic; do not and it was static, cached somewhere you could not easily see. The failure mode was a page you believed was fresh serving hour-old data, or a page you believed was cached hitting your database on every request.
Cache Components inverts it. Nothing is cached unless you opt in. Partial Prerendering is the delivery mechanism: the static parts of a route are prerendered into a shell, the dynamic parts stream in, and the user sees the shell immediately.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
reactCompiler: true,
}
export default nextConfigcacheComponents replaces the old experimental.dynamicIO, and both experimental.ppr and the experimental_ppr route export are gone.The directive
use cache marks a function, a component or a whole file as cacheable. It is composable: cached functions can call other cached functions, and each gets its own entry.
import { cacheLife, cacheTag } from 'next/cache'
export async function getProduct(id: string) {
'use cache'
cacheLife('hours')
cacheTag(`product:${id}`)
return db.product.findUnique({ where: { id } })
}unstable_ prefix on cacheTag and cacheLife was dropped in 16; a codemod handles the rename.cacheLife takes a named profile - seconds, minutes, hours, days, weeks, max - or a custom object. cacheTag attaches labels you can invalidate against later. Together they replace unstable_cache, which is formally deprecated and has no automatic codemod, so that conversion is manual work you should scope before starting.
Invalidation
| Function | When it applies |
|---|---|
revalidateTag(tag, profile) | Mark entries stale. The profile argument is new in 16 and controls how aggressively. |
updateTag(tag) | Invalidate and refresh within the same request, so the response the user gets already reflects the change. |
refresh() | Refresh uncached content on the client without a full navigation. |
revalidatePath(path) | Path-based invalidation, unchanged from 15. |
updateTag is the one to reach for after a mutation - it removes the stale-read-after-write problem that revalidateTag alone leaves you with.What this pushes you toward
The model rewards splitting a page into a cacheable shell and dynamic islands wrapped in Suspense. That is more thought per route than the old implicit behaviour, and it is the point - the caching decision is now written down in the file where it applies, rather than inferred by a system you have to reverse-engineer when something goes stale.
Routing and file conventions
Routes are directories. Files inside them have fixed meanings, which is either pleasantly declarative or an unusual amount to memorise, depending on your taste.
| File | Purpose |
|---|---|
page.tsx | The route's UI. Its presence makes the segment publicly routable. |
layout.tsx | Shared shell that persists across navigations within the segment. |
loading.tsx | Suspense fallback while the segment loads. |
error.tsx | Error boundary. Must be a Client Component. |
not-found.tsx | Rendered by notFound() and unmatched routes. |
route.ts | Route Handler - a JSON API endpoint rather than a page. |
default.tsx | Fallback for parallel routes. Stricter requirements in 16. |
template.tsx | Like a layout, but remounts on every navigation. |
[id]is a dynamic segment,[...slug]catches all,[[...slug]]catches all optionally.(marketing)is a route group - it organises files without appearing in the URL._componentswith a leading underscore is a private folder, excluded from routing.@modalis a named slot for parallel routes, rendered alongside the main children.
Run next typegen to generate typed route helpers, which turns a mistyped href into a compile error instead of a 404 someone finds in production.
Data and mutations
Reads happen in Server Components: await your query directly in the component that needs it. Sibling components fetching in parallel is the default because React renders them concurrently - the client-server waterfall that useEffect fetching produced is structurally absent.
Writes happen through Server Functions. A 'use server' function is callable from the client but executes on the server, with Next.js generating the endpoint. No API route, no fetch call, no manual serialisation.
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import * as z from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
const schema = z.object({
name: z.string().trim().min(1).max(120),
priceCents: z.coerce.number().int().positive(),
})
export async function updateProduct(id: string, formData: FormData) {
const session = await auth()
if (session?.user.role !== 'admin') {
return { error: 'Not authorised.' }
}
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { error: 'Check the highlighted fields.' }
}
await db.product.update({ where: { id }, data: parsed.data })
updateTag(`product:${id}`)
redirect(`/products/${id}`)
}Route Handlers remain the right tool when something other than your own frontend is calling: webhooks, third-party integrations, public APIs, anything needing explicit status codes and headers.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const product = await getProduct(id)
if (!product) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ data: product })
}proxy.ts, formerly middleware.ts
Next.js 16 renamed middleware.ts to proxy.ts and the exported function from middleware to proxy. The logic is unchanged; the rename exists to make clear that this is the network boundary, not a general-purpose interception layer. middleware.ts still works for Edge runtime cases but is deprecated and slated for removal.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export default function proxy(request: NextRequest) {
const session = request.cookies.get('session')
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*'],
}A page, end to end
A product page with a cached shell, a per-user dynamic section, and a mutation. This is the shape Cache Components is designed for.
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getProduct } from '@/lib/products'
import { Recommendations } from './recommendations'
import { StockStatus } from './stock-status'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProduct(id) // cached, tagged, hours
if (!product) notFound()
return (
<article>
{/* Prerendered: same for everyone, served from the shell */}
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Streams in: live inventory, uncached */}
<Suspense fallback={<StockSkeleton />}>
<StockStatus productId={product.id} />
</Suspense>
{/* Streams in: personalised, uncached */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</article>
)
}'use client'
import { useActionState } from 'react'
import { updateProduct } from '../actions'
export function EditProductForm({ id }: { id: string }) {
const [state, action, pending] = useActionState(
updateProduct.bind(null, id),
{ error: null }
)
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" required maxLength={120} />
<label htmlFor="priceCents">Price in cents</label>
<input id="priceCents" name="priceCents" type="number" min={1} required />
{state.error && (
<p role="alert" aria-live="polite">{state.error}</p>
)}
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Save'}
</button>
</form>
)
}action on a form element is a platform feature Next.js progressively enhances.Turbopack and build performance
Turbopack has been the default bundler since 16, replacing webpack for both next dev and next build. Filesystem caching persists compiler artifacts between runs, so a restart is not a cold start. Vercel reports roughly 400% faster dev startup and 50% faster rendering in 16.2, and further memory and build-cache work in the 16.3 preview.
Those are the vendor's numbers on their benchmarks. The consistent report from teams moving off webpack is that the improvement is real and largest on big applications. The cost is elsewhere: a custom webpack configuration does not carry over, and next build --webpack is the escape hatch while you port loaders and plugins.
Deployment and self-hosting
Next.js is built by a hosting company, and the framework's most advanced features land on that hosting first. That is a real consideration, and it is also less of a trap than it was: 16.2 shipped a stable Adapter API and Vercel published explicit commitments about cross-platform support, with OpenNext as the community project covering Cloudflare, AWS and others.
| Target | What you get | What to check |
|---|---|---|
| Vercel | Zero configuration, every feature, ISR and image optimisation managed | Cost at scale - image and function usage are the usual surprises |
| Self-hosted Node.js | Full control, output: 'standalone' produces a minimal server | ISR needs shared storage across instances; image optimisation needs sharp and CPU headroom |
| Docker or Kubernetes | Standard container workflow | Build-time versus runtime environment variables; NEXT_PUBLIC_ values are inlined at build |
| Cloudflare and AWS via OpenNext | Community adapters over the stable Adapter API | Feature parity per adapter - verify the specific features you use |
| Static export | output: 'export' for a pure static site | No Server Functions, no ISR, no image optimisation, no proxy |
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]output: 'standalone' in next.config.ts. The standalone bundle includes only the dependencies actually traced.Security and the release cadence
This section exists because most Next.js writing skips it, and it is the part that will cost you if you get it wrong.
Next.js publishes a support policy of two years per major version. Version 16 is Active LTS. Version 15 is in maintenance and leaves support in October 2026. Version 14 has been end of life since October 2025 and receives nothing.
In July 2026 Vercel formalised security releases into a regular schedule with advance notice - a direct response to the preceding period. December 2025 brought a CVSS 10.0 remote code execution issue in the React Server Components protocol. May 2026 brought a coordinated release covering 13 advisories across middleware and proxy bypass, denial of service, SSRF, cache poisoning and cross-site scripting. July 2026 brought four high and five medium severity issues.
- Subscribe to the Next.js blog's security tag and treat those posts as scheduled work, not optional reading.
- Authorise in the page, layout or handler that reads the data. Never rely on the proxy alone.
- Validate every Server Function input server-side, and check the session inside the function body.
- Never pass secrets or full user records across a
'use client'boundary - props are serialised into the HTML. - Restrict
images.remotePatternsto hosts you control; a permissive image config is an SSRF surface. - Keep
NEXT_PUBLIC_for values you would happily print in view-source, because that is where they end up.
Upgrading from 15 to 16
Smaller than 14 to 15 for most App Router projects, but not mechanical. The work concentrates in two places: webpack configuration and caching.
- Branch, and confirm you have a clean build and a passing test run on 15 first.
- Audit your
next.configfor webpack customisation and your codebase forunstable_cache. These two determine the size of the job. - Run
npx @next/codemod@canary upgrade latest. It handles themiddlewaretoproxyrename, theunstable_prefix drops, the Turbopack config move and theexperimental_pprremoval. - Fix async request APIs.
params,searchParams,cookies(),headers()anddraftMode()are async only - the 15 compatibility shim is gone. - Build with Turbopack and work through what breaks.
next build --webpackremains available while you port. - Ship the upgrade without
cacheComponents. Getting onto 16 and adopting the new caching model are two separate projects. - Then enable
cacheComponents, route by route, starting with the pages where caching behaviour is already well understood. - Convert
unstable_cachecall sites touse cachewithcacheLifeandcacheTag. There is no official codemod for this one. - Add
default.tsxfiles where parallel routes now require them. - Verify your monitoring, then roll out gradually with a rollback path you have actually tested.
| Symptom after upgrading | Cause |
|---|---|
| Build fails on a config key | Turbopack config moved out of experimental; next lint was removed |
| A page that was static is now dynamic | Caching is opt-in - it needs an explicit use cache |
Type errors on params or searchParams | They are Promises now; await them or run next typegen |
| Parallel route renders nothing | Missing default.tsx in a slot |
| Proxy deprecation warnings | middleware.ts still present - rename the file and the exported function |
| Images look different or fail | next/image defaults changed in 16 |
Where Next.js hurts
The learning curve is genuinely steep
Server Components, client boundaries, streaming, Suspense, Cache Components, Server Functions, route conventions, the proxy. A React developer who has not followed the App Router closely is looking at weeks before they are productive and months before they stop introducing subtle caching and boundary mistakes.
Churn
Three major versions in three years, each with a significant paradigm change: the App Router, async request APIs and caching semantics, then explicit caching and a new bundler. Every one was defensible on its merits. Collectively they mean a Next.js codebase needs deliberate maintenance budget in a way a Django or Rails codebase does not.
Debugging across the boundary
When something renders wrong, the question is where - the server render, the hydration, the cache, the streamed boundary. Error messages have improved a great deal, and 16 added an MCP server and agent tooling aimed squarely at this. It is still harder than debugging a single-process application.
Vendor gravity
Vercel builds Next.js and sells Next.js hosting. Features land there first and work there best. The stable Adapter API and the OpenNext collaboration have narrowed the gap materially, and self-hosting is a supported path rather than a fight. But the framework's roadmap is set by a company whose business is the platform, and pretending otherwise is not analysis.
Security surface
The RSC protocol, Server Functions and the proxy layer are all network-facing attack surface that a client-rendered SPA with a separate API simply does not have. The advisory history is what it is. This is a manageable cost with a patching discipline, and it is a cost.
It is a lot of framework
For a marketing site, a blog or a documentation portal, Next.js is more machinery than the problem needs. Astro will ship less JavaScript with less configuration for content-driven work. Reach for Next.js when the application part is the point.
The alternatives
| Option | Strongest at | Weakest at | Choose it when |
|---|---|---|---|
| Next.js | Full-stack React, ecosystem depth, hiring pool | Complexity, churn, vendor gravity | You are building an application and the team knows React |
| Remix / React Router | Web standards, simpler data model, less caching machinery | Smaller ecosystem, fewer built-in optimisations | You want React with a more conventional request lifecycle |
| Astro | Content sites - minimal JavaScript by default, any UI framework | Heavily interactive applications | The page is mostly content with islands of interaction |
| SvelteKit | Small bundles, coherent design, pleasant to write | Smaller hiring pool and library ecosystem | Team skill allows it and bundle size matters |
| Nuxt | The Vue equivalent, comparable maturity | Only relevant if you are on Vue | Your team writes Vue |
| React SPA plus a separate API | Clear separation, deploy anywhere, no framework churn | You build routing, SSR and SEO yourself | SEO does not matter and the app is behind a login |
When to use it, and when not to
Next.js is the right call when
- You are building an application with meaningful interactivity and a React team.
- SEO and first-load performance both matter, which rules out a plain SPA.
- You want frontend and backend in one deployable unit with one language.
- You are hiring - the React and Next.js talent pool is the largest available.
- You are on Vercel, or your platform has a maintained adapter you have verified.
Look elsewhere when
- The site is mostly content. Astro ships less and asks less of you.
- Your team is small, new to React, and needs to be productive this month.
- You cannot commit to a regular upgrade and patching cadence.
- Your backend already exists in another language and only needs a frontend.
- Your compliance posture cannot accommodate a framework with this advisory history and this release cadence.
Verdict
Next.js 16 is the best version of the framework and the most demanding one. Cache Components fixed the thing that made the App Router genuinely hard to reason about - caching you could not see - by making the decision explicit and local. Turbopack made the development loop fast. The Adapter API made self-hosting a supported path rather than an act of defiance.
What has not changed is the size of the thing. This is a framework that expects you to understand rendering boundaries, cache lifetimes and the difference between code that runs on the server and code that merely looks like it does. Teams that invest in that understanding get an application platform with very few real peers. Teams that adopt it because it is the default React choice tend to end up with a slow, over-hydrated site and a caching model nobody can explain.
Upgrade to 16 and adopt Cache Components as two separate pieces of work. Doing them together turns a manageable migration into a debugging exercise with two variables.
If you are starting fresh and building an application, Next.js 16 is a sound default. If you are on 15, plan the upgrade now - support ends in October 2026, and patches only land on the current minor. If you are on 14, you are already unsupported, and that is the more urgent problem.
Sources
- Next.js blog - release announcements and security advisories
- Next.js 16 - Cache Components, Turbopack, proxy and breaking changes
- Upgrading to version 16 - codemods and async request APIs
- Next.js 16.2 - development and rendering performance
- Next.js 16.3: Instant Navigations - preview features
- Next.js Across Platforms - the Adapter API and OpenNext
- July 2026 Security Release and the security release programme
- Support policy - LTS windows per major version



