We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide12 min read

Next.js 16, the end of caching nobody understood

Next.js 16 inverts caching: with the cacheComponents option on, nothing is cached by default. Server components, use cache, Turbopack, proxy, and migration.

Next.js 16, the end of caching nobody understood

The greatest source of frustration in earlier versions of this framework was neither server components nor server actions but caching. It applied by default across several layers at once, and answering "why am I seeing stale data" required knowing four different mechanisms.

Version sixteen inverts that model. With one option enabled in the configuration, nothing is cached by default and whatever should be is marked explicitly. The change sounds small and is the largest in this framework since the app router arrived.

Explicit caching

Before any of this works, the model has to be switched on in the configuration, and that is the commonest reason examples found online end in a build error. The whole mechanism sits behind one option.

Code
TypeScript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true
}

export default nextConfig

Without it the caching directive, the lifetime, and the tag are unavailable, and the project behaves as it did in the previous version, where route segment settings decided caching. The same option turns on the partial prerendering described below, since the separate flag that used to govern it has been removed. The model also requires the Node runtime, so routes marked for the edge have to be moved first.

A directive placed in a function or component states that its result should be remembered.

Code
TypeScript
async function getArticles(category: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`articles-${category}`)

  const data = await db.articles.findMany({ where: { category } })
  return data
}

Three things happen explicitly here. The first is caching itself. The second is entry lifetime, given as a named profile rather than a second count scattered through the code. The third is a tag allowing that specific entry to be invalidated after a data change.

Code
TypeScript
'use server'

export async function saveArticle(data: FormData) {
  await db.articles.create({ data: process(data) })
  revalidateTag(`articles-${data.get('category')}`)
}

That construction solves the commonest problem of earlier versions: data a user changed that never appears in a list, because the caching layer knows nothing about the change. Invalidation now sits visibly in the code beside the write.

The price of that explicitness is one thing: a page that used to be fast by accident is now slow until you mark it. During migration that is the commonest surprise.

Partial prerendering

The second mechanism works with the first and deserves understanding, since most of the gain comes from it.

A page usually consists of an unchanging part and a user dependent part. Header, footer, product description, and article body are identical for everybody. A basket, a greeting by name, and a recently viewed list are individual.

The classic approach forces a choice: either the whole page is static with no individual parts, or the whole page is dynamic and regenerates on every request. Partial prerendering allows both: a static shell reaches the user immediately while request dependent fragments follow.

Code
TypeScript
export default function ProductPage({ params }) {
  return (
    <>
      <ProductDescription id={params.id} />
      <Suspense fallback={<BasketSkeleton />}>
        <UserBasket />
      </Suspense>
    </>
  )
}

The suspense boundary marks the line. Everything outside it must be generatable ahead of time, so reaching for request headers or cookies beyond that boundary is an error the framework reports at build time.

Turbopack by default

The new bundler stopped being a flag enabled option and is now the default in both development and production builds.

The difference registers on large projects. Refresh after a code change counts in tens of milliseconds rather than seconds, and production builds shorten several times over. On a twenty component project you will not notice; on a two hundred component one the difference changes the rhythm of work.

Filesystem caching also arrived, so a later start after a restart does not begin from nothing. That matters when working across branches, where switching previously meant a full rebuild.

Check your own configuration when switching. Projects using custom settings for the previous bundler need them translated, and some plugins have no counterpart. On a typical project with no custom configuration the move goes unnoticed, since it works out of the box.

The naming misleads in monorepos. Turbopack bundles the code of one application, while Turborepo splits tasks across packages and remembers their results, so a package nobody touched is not built twice. The gain appears only once several applications or libraries share the repository; with a single one it is configuration and nothing more.

Breaking changes

Three things need attention during migration and deserve knowing before starting.

The first is the intermediary layer. The file handling requests before they reach a route was renamed to make clear it runs at the network boundary rather than inside the application. That is a file rename plus a review of what sits inside, since some of it never belonged there.

The second is the required interface library version. The framework needs a release containing the mechanisms partial prerendering rests on, so raising one pulls the other along.

The third is caching, covered above. Raising the version alone does not change it, since the new model comes behind its own option. Once that is on, code relying on the old behaviour still works, only slower, since it caches nothing. That is not an error you see in a console but an increase in response time and in the function bill. Check the runtime version while you are there, since version sixteen requires Node 20.9 or newer.

The practical migration order: raise versions, run it, review response times, and then add caching markers where you see degradation. The reverse order, marking everything up front, leads to caching things that should not be cached.

Server components in practice

The server and client component split has been with us for several versions and still causes confusion.

A component is server side by default. It runs on the server, reaches the database and secrets, and the browser receives the result rather than the code. The directive marking a component as client side is needed only when you want state, effects, or browser events.

The commonest mistake marks components as client side too high in the tree. The directive applies to the whole subtree, so marking a layout moves everything beneath it into the browser, including code that could have stayed on the server.

The right approach pushes that boundary as low as possible. An interactive button is a client component while the list containing it stays server side. With component libraries, Mantine for instance, that usually means wrapping them in your own client components rather than marking whole pages.

Server actions

A mechanism for calling a server function straight from a component without writing an interface route. Convenient and worth understanding, since it gets used carelessly.

Code
TypeScript
'use server'

import { auth } from '@/lib/auth'

export async function deleteComment(id: string) {
  const session = await auth()
  if (!session) throw new Error('Not authenticated')

  const comment = await db.comments.findUnique({ where: { id } })
  if (comment.authorId !== session.userId) throw new Error('Not permitted')

  await db.comments.delete({ where: { id } })
  revalidateTag('comments')
}

The two checks at the start are not surplus. A server action is an endpoint reachable from outside, so anybody can call it with any identifier. Hiding a button in the interface protects nothing.

The second thing is input validation. Arguments arrive from the browser, so a type in the function signature is a declaration rather than a guarantee. A schema checking input is mandatory here exactly as in an ordinary route.

The third is error handling visible to the user. An exception thrown in an action reaches the component, and without handling it ends as a generic message, so returning a result describing what went wrong is better.

Images and fonts

Two built in mechanisms with the largest effect on speed measurements, and both often skipped.

The image component sizes for the device, converts format, and reserves space so the layout does not jump after loading. That last part matters most, since content shifting during load is one of the measured metrics.

Code
TypeScript
import Image from 'next/image'

<Image src="/product.jpg" alt="Product view" width={800} height={600} priority />

The priority marker applies to images visible without scrolling. Without it the browser loads them alongside everything else, so the page's largest element appears later than it could, and that is measured and feeds into the speed score.

The font mechanism fetches them at build time and serves them from your domain rather than querying an external provider on every visit. That removes one network connection and the text flicker when a fallback font swaps for the intended one, and along the way one place where visitor data leaves your site.

Next.js against the alternatives

FrameworkStrengthWeaknessPick it when
Next.jsServer components, ecosystem, deployment at its makerComplexity, frequent model changesApplication with an interface and a back end
AstroLightest output, content firstLess convenient with heavy interactivityContent sites, blogs, documentation
RemixSimpler data model, closer to web standardsSmaller ecosystemApplication with forms and data
Hono with a separate front endLayer separation, light weightTwo deployments rather than oneProgramming interface and front end apart

Choosing between the first two rows depends on the ratio of content to interactivity. A blog or documentation needs neither a server layer nor client components on every page, and a lighter output translates directly into load time.

Consider the last row once the back end grows into a standalone product. Keeping a programming interface in the same project as the front end is convenient at first and becomes a constraint once something else starts using that interface.

It is only fair to add that this framework changes its working model more often than the competition. The move to the app router, then to server components, now to explicit caching: each of those required rethinking a project. That is the price of staying close to what happens in the interface library, and it deserves counting on a project meant to live for several years without major change.

On the other hand, none of those changes invalidated the previous version overnight. The pages router still works, and applications from earlier generations can be developed without migrating. The cost lies rather in material and examples online describing different versions, so finding an answer means checking which one it refers to.

Project structure

A directory based router means the file structure is also the URL structure, and that needs a few decisions.

Files with reserved names carry special meaning: page, layout, loading state, error handling, missing resource. Other files in a route directory are ordinary code, so components used by one page alone can sit beside it rather than in a shared directory.

Route groups, written as a name in brackets, let you split a project without affecting URLs. The typical use separates the public part from a panel behind a login, where each has its own layout while URLs stay flat.

Code
TEXT
app/
  (public)/
    layout.tsx
    page.tsx
  (panel)/
    layout.tsx
    settings/page.tsx

Settle early where non route code lives. Keeping everything in the route directory works at ten pages and turns unreadable at fifty, since telling a URL handling file from a helper becomes hard.

Error handling is a separate matter. The error file catches exceptions from its subtree, so where you place it decides whether a user sees a message instead of the whole page or instead of one section.

Common mistakes

The first is a client directive in a layout. It moves the whole subtree into the browser, so server components stop being server side.

The second is reaching for request headers in a part meant to be generated ahead of time. The framework reports it at build time, though the cause is often unobvious, since the call sits several levels down.

The third is migrating without checking response times. The code works and simply stopped caching, while the serverless function bill grows quietly.

The fourth is invalidating whole paths rather than tags. Refreshing everything on every change cancels the caching gain and loads the back end.

The fifth is server actions without permission checks. They are ordinary endpoints reachable from outside, so checking who calls belongs inside them rather than in the interface that shows them.

The sixth is fetching data in a client component where a server one would do it better. The result is an extra round trip and a loading state flicker that need not exist.

FAQ

What changed in version sixteen?

Caching became explicit: with the cacheComponents option enabled in the configuration, nothing is cached by default and whatever should be is marked with a directive carrying a lifetime and a tag. That same option replaced the earlier partial prerendering flag. The new bundler became the default in development and production, and the intermediary layer file was renamed.

Is migrating from version fifteen hard?

Usually not, since code relying on the old behaviour still works and simply stops caching. The main work reviews response times and adds markers where degradation shows, plus renaming the intermediary layer file.

When must a component be client side?

When it uses state, effects, browser events, or interfaces available only on the client. In every other case leave it server side, since the code then never reaches the browser and it reaches the database directly.

Does Next.js suit content sites?

Yes, though for a blog or documentation lighter frameworks give a smaller output and a simpler model. This framework wins once login, a user panel, or a back end joins the content, since it keeps all of that in one project.

Must it be deployed at the framework's maker?

No, though integration runs deepest there and some features work without configuration. Alternatives include Netlify, Cloudflare, and container deployment on your own server, and partial prerendering support deserves checking at your chosen provider.

Documentation sits on the project site, and the version sixteen changes appear in the release announcement.