Artigo

How to Fix bf-cache Issues and Avoid Non-cacheable Pages in Next.js

Learn how bf-cache affects your site’s performance, why pages can be blocked from caching, and how to resolve this in Next.js without exposing user data.


I recently ran PageSpeed Insights on my blog and stumbled upon a warning I’d never seen before:

Page prevented back/forward cache restoration — 3 failure reasons

It was flagged as “not scored” — meaning it doesn’t hurt your score directly, which probably leads many to ignore it. But there’s a real cost: every time someone clicks the back button to return to your page, instead of an instant restore (no requests, JS state preserved), the browser fully reloads the page from scratch. This post is about how I hunted down the cause — and how my first theory was completely wrong.

What is bf-cache, in a nutshell?

The back/forward cache is a mechanism found in pretty much every modern browser: when you navigate away from a page, the browser freezes the entire page (DOM, JS state, everything) in temporary memory. If you hit back, it simply thaws the page — no new requests, no full JS re-execution. Instantaneous.

The problem: there’s a list of things that disqualify a page from using bf-cache, and the most common one is simple — an HTTP header Cache-Control: no-store on the HTML response. If the browser sees this, it assumes the page holds sensitive data and won’t cache it at all.

The first (incorrect) suspect: authentication

My blog uses NextAuth, so my first instinct was clear: “must be session data leaking into the server-side render.” It’s a classic cause — if a page reads cookies(), headers(), or anything session-related during server rendering, Next.js automatically flags the response as dynamic and adds Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate. Makes sense: you don’t want the CDN serving a cached page to two different logged-in users.

But after combing through the code for any use of cookies(), headers(), or getServerSession() in the pages, I found zero occurrences. None were reading session state on the server — all login state was handled client-side using useSession(). The theory didn’t hold up.

Manual header comparison

Instead of continuing to guess, I went straight to curl:

# A route that passes through our middleware
curl -sD - https://prdev.com.br/pt-BR/about -o /dev/null | grep -i cache-control
# Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

# A route that does NOT pass (excluded in the middleware matcher)
curl -sD - https://prdev.com.br/robots.txt -o /dev/null | grep -i cache-control
# Cache-Control: s-maxage=31536000

The /about page had no authentication code at all. Yet, it returned no-store — just like the home page, blog, and post pages. The only truly cacheable route was the one that didn’t even go through middleware. That pointed to a prime suspect: the middleware itself.

The twist: right conclusion, wrong reason

Before rushing into fixes, I checked the official Next.js docs to be sure. That’s when the investigation got interesting — because there was a flaw in my test method: I compared /robots.txt (which skips middleware and also isn’t within the [lang] dynamic route) against pages that pass through middleware and are under [lang]. I had changed two variables at once.

Next.js documentation is very clear:

"Dynamically rendered pages set a Cache-Control header of private, no-cache, no-store, max-age=0, must-revalidate to prevent user-specific data from being cached."

That’s when it clicked: my project had no generateStaticParams implemented anywhere for the [lang] segment. This meant every page under it — home, blog, posts — was rendered dynamically by default, and Next would apply this header regardless of middleware.

Middleware wasn't the root cause. But, as coincidence would have it, it was still the right place to fix things — since it's the only server layer running before the final response, and middleware responses can explicitly overwrite this header if you need to.

The fix

The key detail: my middleware also guards protected routes (/blog/exclusive/*). If I just forced Cache-Control: public everywhere, a logged-out user hitting back could briefly see a bf-cached page with their session still active. So the fix had to distinguish between public and protected pages:

// Protected route: never overwrite. Losing bf-cache here is the price
// for not risking exposing private content after logout.
if (isProtectedPath(pathname)) {
  const token = await getToken({ req, secret });
  if (!token) return NextResponse.redirect(loginUrl);
  return NextResponse.next();
}

// Public route: nothing here depends on session, so it's safe
// to overwrite Next's default no-store and enable bf-cache.
const res = NextResponse.next();
res.headers.set("Cache-Control", "public, max-age=0, must-revalidate");
return res;

Real validation

Rebuild, deploy, run Lighthouse again:

  • The bf-cache warning disappeared from public pages.
  • Tested access to a protected route without a session — still redirected to login as before.
  • Checked the headers via curl again: public, max-age=0, must-revalidate on public pages, and previous protected behavior intact.

The lesson

Actually, two lessons:

  1. A test that "confirms" your hypothesis might just be confirming the wrong thing. I had a real correlation (middleware ⇒ no-store), but the causation wasn’t quite right. Only caught this because I stopped to read the documentation before blindly fixing and moving on.
  2. "Fixing where you can" isn’t always the same as "fixing the root cause" — and that’s okay, as long as you know the difference. Middleware was never the core problem, but it was the only spot with access to the final response. Understanding this kept me from misapplying a fix in the wrong place (like tinkering with generateStaticParams just for caching, which would have been unnecessarily disruptive for the actual goal).