Next.js 16, Cache Components and React Compiler: what really changes
· 7 min read · Filippo Spinella · Next.js, React, Frontend, Performance
For years one of the most annoying questions in Next.js has been: "Is this page static or dynamic?". It seems like a simple question, until you add a call to cookies(), a fetch with different options, a database client, a CMS, a shopping cart, or a piece of custom content.
Next.js 16 is interesting because it tries to make this conversation less mysterious. It doesn't eliminate complexity, but it shifts the mental model: routes are dynamic by default, the cache declares itself where needed, and Suspense becomes the natural way to compose fast shells with parts that stay fresh.
The feature to understand is Cache Components. Stable Turbopack, React Compiler, proxy.ts, and the new invalidation APIs are important, but they revolve around the same problem: building fast apps without having to guess what the framework decided behind the scenes.
Because this thing matters
In a real app you don't just have "static pages" and "dynamic pages". You have different pieces with different needs.
The product sheet may change a few times a day. The price may change more often. Availability must be almost live. The username is personal. Reviews can be streamed. The sidebar can be stable. The cart does not.
If you treat everything as one unit, you always end up in one of two extremes:
- aggressive caching and risk of seeing old data;
- dynamic rendering everywhere and performance worse than necessary.
Cache Components serves precisely to avoid this false choice.
The model in practice
With cacheComponents: true, you can declare what is cacheable using "use cache". Then you can associate duration and tags with cacheLife() and cacheTag(). Dynamic parts remain dynamic and can be isolated with Suspense.
The setup is small:
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { cacheComponents: true, }; export default nextConfig;
The big change is not in the config. It's in how you start writing the components.
// app/products/[slug]/page.tsx import { Suspense } from 'react'; import { cacheLife, cacheTag } from 'next/cache'; async function getProduct(slug: string) { 'use cache'; cacheLife('hours'); cacheTag(`product:${slug}`); return db.product.findUnique({ where: { slug } }); } async function ProductDetails({ slug }: { slug: string }) { const product = await getProduct(slug); return ( <section> <h1>{product.name}</h1> <p>{product.description}</p> </section> ); } async function LiveInventory({ slug }: { slug: string }) { const inventory = await db.inventory.findFirst({ where: { slug } }); return <p>{inventory.quantity} pezzi disponibili</p>; } export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; return ( <> <ProductDetails slug={slug} /> <Suspense fallback={<p>Controllo disponibilità...</p>}> <LiveInventory slug={slug} /> </Suspense> </> ); }
The page does not have to be all cached or all dynamic. The product sheet can be fast and reusable. Inventory can stay fresh. The user sees something right away, without waiting for the slowest part.
use cache is executable documentation
The thing I like about "use cache" is that it forces you to make an intention explicit. When you read a function, you immediately understand that someone has decided: "this data can be reused".
It's especially useful when you're not using fetch. Many apps read data from Prisma, Drizzle, internal SDKs, CMS clients or service functions. In those cases the old reasoning based only on fetch options wasn't enough.
A rule of thumb:
- cachea content relatively stable;
- use granular tags;
- leaves dynamic permissions, sessions, carts, notifications and transactional states;
- put slow parts inside
Suspense; - measure before saying "we improved performance".
Invalidate without throwing everything away
The cache is only useful if you can update it accurately. Here cacheTag, revalidateTag and updateTag become important.
Example:
'use server'; import { updateTag } from 'next/cache'; export async function updateProductName(productId: string, name: string) { await db.product.update({ where: { id: productId }, data: { name }, }); updateTag(`product:${productId}`); }
The important detail is the tag. product:${productId} tells a precise boundary. products tells a huge bucket. At first the huge bucket is comfortable; after a few months it becomes the reason you invalidate half an app to change a title.
Stable Turbopack: the part you hear every day
Next.js 16 brings Turbopack to the center for development and build. It's not the most poetic feature, but it's the one you feel while you work: server that starts earlier, faster refresh, builds that stop feeling like a forced coffee break.
That said, I wouldn't migrate a codebase full of custom plugins with my eyes closed. I would check:
- local build;
- non-standard import;
- MDX, SVG and CSS;
- Webpack plugins left;
- critical pages;
- differences in build times.
For new projects, I would start from the default. For mature ones, I would do a measured migration.
React Compiler: remove noise, not thought
React Compiler 1.0 is stable and Next.js 16 supports it with reactCompiler. The promise is to reduce a lot of manual memoization: less memo, less useMemo, less useCallback used "for safety".
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { reactCompiler: true, }; export default nextConfig;
I wouldn't treat it like a magic dust. The compiler helps when the code follows React rules well. If components have strange side effects, hidden mutations or badly used hooks, that needs to be fixed first.
The healthy way to try it:
- update
eslint-plugin-react-hooks; - fix actual violations;
- enable it on a controlled area;
- measure build time and behavior;
- remove manual memoization only when it is no longer needed.
The goal is not to erase every useMemo. The goal is to stop writing preventive memoization because we are afraid of rendering.
proxy.ts and the network boundary
The old middleware.ts becomes proxy.ts. It's a name change, but it makes sense: that file sits at the request boundary, it's not traditional backend-style generic middleware.
// proxy.ts import { NextRequest, NextResponse } from 'next/server'; export default function proxy(request: NextRequest) { const isLoggedIn = Boolean(request.cookies.get('session')); if (!isLoggedIn && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } return NextResponse.next(); }
The rule here is simple: keep it small. Redirect, auth routing, headers, essential rewrites. If it starts to feel like a second backend, it's probably doing too much.
How I would really migrate
I wouldn't turn on all the features at once. I would do this:
- I update Next, React and React DOM;
- I launch the official codemod;
- I fix breaking changes on
params,searchParams,cookies(),headers()anddraftMode(); - I migrate
middleware.tstoproxy.ts; - I check builds and critical pages;
- I enable Cache Components on a section where the cache currently creates friction;
- I define conventions for tags and invalidation;
- I try React Compiler separately;
- comparison of metrics before and after.
The good migration is not the one that uses all the new features. It's what makes the app's behavior more readable.
What changes in the way of thinking
The most useful thing about Next.js 16 is that it forces you to name intentions better. A function is not just "get the product from the database". It's "get the product, I can cache it for hours, I invalidate it with this tag". A component is not just "render the page". It's "this is the fast shell, this piece is personal, this comes streaming."
At first it seems like more work. Then it becomes a form of calm. Performance decisions are no longer hidden in a combination of defaults, heuristics and tribal memory. They're in the code.