Skip to content

Next.js App Router Caching: The Defaults That Burned Me

Next.js App Router Caching: The Defaults That Burned Me

Confession: I once shipped a pricing page that showed customers the wrong number for eight hours. Not a rounding error. A genuinely stale price, cached from a deploy two days earlier, served to real people while I refreshed my own browser in incognito and saw the correct value every single time. That’s the worst kind of bug. The one you can’t reproduce because your own machine is lying to you.

The culprit was Next.js caching. Not a bug in Next, to be fair. It was doing exactly what version 14 told it to do, which was cache my fetch call and never ask the server again. I just didn’t know that was the default. So when Next.js 15 flipped that default on its head, I had feelings about it. Here’s what actually changed in the App Router, why I think they got it right, and the mental model that finally stopped me from getting burned.

The short version, for the impatient

In Next.js 14, fetch was cached by default. In Next.js 15, it isn’t. Same story for GET route handlers, which used to be cached and now aren’t. If you upgraded and your data suddenly feels fresher, or your upstream API bill crept up, that’s why. The team documented all of it in the Next.js 15 upgrade guide, and honestly it’s worth ten minutes even if you think you know what changed.

If you only take one thing from this post: caching went from opt-out to opt-in. That single change is the whole story.

What fetch used to do, and exactly how it bit me

Here’s the code that ruined my afternoon. In Next.js 14, this fetch was cached the first time it ran and then frozen until I deployed again:

// Next.js 14: cached by default, served forever
async function getPrice() {
  const res = await fetch('https://api.example.com/price')
  return res.json()
}

Nothing in that snippet says “cache this.” That was the point, and the problem. The default was force-cache, so the first response got pinned and every later visitor saw the same stale value. To get fresh data, you had to remember to opt out:

// Next.js 14: you had to ask for fresh data
const res = await fetch('https://api.example.com/price', { cache: 'no-store' })

I forgot the no-store. Of course I did. The data on a pricing page changes maybe once a month, so for weeks it looked fine. Then marketing changed a number, the new deploy went out, an old cached response stuck around on one route, and a handful of customers saw a price we no longer offered. My local machine showed the right value because dev mode behaves differently, which is why I spent an hour convinced I was losing my mind.

What Next.js 15 changed

Next.js 15 made the default uncached. A plain fetch now goes to the server on every request unless the route gets statically prerendered at build time. You can read the reasoning straight from the team in the Next.js 15 release notes.

So the same code does the opposite thing now:

// Next.js 15: fresh by default
const res = await fetch('https://api.example.com/price')

// Opt INTO caching when you actually want it
const cached = await fetch('https://api.example.com/price', { cache: 'force-cache' })

// Or cache with a timer, which is what I use most
const timed = await fetch('https://api.example.com/price', {
  next: { revalidate: 3600 }, // re-check at most once an hour
})

GET route handlers got the same treatment. In Next.js 14 a GET handler was cached unless you used a dynamic function inside it. In 15 it runs every time, and you cache it on purpose:

// app/api/price/route.ts
// Next.js 15: opt into caching explicitly
export const dynamic = 'force-static'

export async function GET() {
  const data = await getPrice()
  return Response.json(data)
}

The client-side router cache changed too. Page segments you navigate to are no longer held with a stale time by default, so clicking back to a page you visited a minute ago refetches instead of showing a frozen copy. That one is quieter, but it’s the reason some people felt navigation got “slower” after upgrading. It didn’t get slower. It got honest.

Why I think the flip was the right call

A few people grumbled that uncached-by-default makes Next feel less magic and more work. I disagree, and I’ve got the support ticket to prove it.

Here’s my reasoning. A slow page is annoying. A wrong page is a lie you’re telling your users with a straight face. When the dangerous default is the silent one, you don’t find out you chose wrong until someone emails you. Opt-in caching reverses that. The worst case for the new default is a fetch you forgot to cache, which shows up as a slightly slow response and maybe a higher bill, both of which you’ll notice in normal testing. The worst case for the old default was the bug I shipped, which nobody notices until it’s in front of a customer.

There’s a deeper version of this argument. Caching is a performance optimization, and performance optimizations should be deliberate. You should be able to point at a revalidate value and say why it’s an hour and not a minute. When caching is automatic, nobody owns that decision, so nobody can defend it later. I made a similar point when I wrote about the server-action routes I deleted in production: the framework being clever on your behalf is great right up until you need to explain what it did.

The mental model that finally stuck

I stopped trying to memorize the caching matrix. Instead I ask one question at every fetch: does this data need to be correct right now, or correct within some window I can name?

If it has to be correct right now, like a price, a balance, or anything tied to money, I leave the default alone and let it hit the server. If it’s correct within a window, I add revalidate with a number I can justify out loud. If it basically never changes, like a marketing blurb or a list of countries, I reach for force-cache and move on.

That’s it. One question, three answers, and the answer lives right next to the fetch where the next person can see it. The framework default now matches the safe answer, which means forgetting to think about it fails toward correctness instead of toward a stale lie.

This is also why I still keep a foot in the Pages Router for a couple of older projects, which I get into in the post on when I still reach for it. Different caching story, different tradeoffs, and sometimes the boring tool is the one that lets me sleep.

What to do this week

Open the project you upgraded, or are about to, and grep for fetch( across your server components and route handlers. For each one, ask the single question above and write the answer into the code, either as a cache option, a revalidate, or a one-line comment saying “intentionally dynamic.” It takes maybe twenty minutes on a medium codebase and it turns caching from a thing that happens to you into a thing you decided.

If you want to see how I wire this kind of audit into a real project, I keep a few build notes and examples in my work. Start with the money-touching fetches. Those are the ones that send the emails.