Skip to main content
dario's.blog
Back to posts

The Next.js bloom filter and how it can break App Router migrations

If you're incrementally migrating a Next.js app from the Pages Router to the App Router and some of your links have started mysteriously 404-ing (or worse, triggering full page reloads to URLs with a doubled basePath), you're not alone. This is a known class of bug happening on a range of Next.js versions related to a feature called the client router filter. I ran into it myself on a project where we were migrating a Pages Router app to the App Router.

Running two routers in parallel

When Next.js introduced the App Router (app/ directory), it didn't replace the Pages Router (pages/ directory) overnight. Instead, it let both coexist in the same application so teams could migrate incrementally. However, when a user clicks a link, which router should handle it?

The Pages Router does smooth, SPA-style client-side transitions. The App Router uses React Server Components and needs the server to render the page. If the Pages Router tries to handle a route that actually belongs to the App Router, it'll break. The navigation needs to be a hard navigation (a full page reload) so the server can take over. Next.js needs a way, on the client, to figure out whether a given path belongs to app/ or pages/. This is done through the client router filter.

At build time, Next.js takes every route defined in your app/ directory and encodes them into a bloom filter, a compact, probabilistic data structure. This bloom filter gets shipped to the browser as part of your client-side JavaScript bundle. At runtime, whenever the Pages Router is about to perform a client-side navigation, it checks the destination path against this bloom filter:

  • If the bloom filter determines that it's definitely not an app route, then proceed with normal client-side navigation. No page reload.
  • If the bloom filter determines that it "might be an app route", then trigger a hard navigation (window.location.href = ...) so the server handles it.

The key word here is "might." Bloom filters are probabilistic. They never produce false negatives (if a route is in the app/ directory, the filter will always catch it), but they can produce false positives, occasionally flagging a Pages Router route as potentially belonging to the App Router.

Next.js configures the default false positive rate at 0.01%, so in most apps this never surfaces, unless you have a basePath configured.

Usage of basePath

If your app uses a basePath in next.config.js (say, basePath: "/dashboard") and the bloom filter produces a false positive for one of your Pages Router routes, two things can go wrong:

  1. The false positive triggers a hard navigation for a route that doesn't need one.

Your user clicks a <Link> to /activities, which is a perfectly valid Pages Router page. The bloom filter incorrectly determines "that might be an app route", so Next.js does a full page reload instead of a smooth client-side transition.

  1. The hard navigation code path duplicates the basePath.

The code responsible for building the hard-navigation URL prepends the basePath to a URL that already includes it. So instead of navigating to:

/dashboard/activities

The browser gets sent to:

/dashboard/dashboard/activities

That URL doesn't exist and you get a 404. And because bloom filter false positives are determined by hash functions and the specific set of routes in the filter, which routes break is essentially random. One route might 404 while a nearly identical sibling works fine.

How to check if this problem exists in your app

There are a few things worth checking:

  • You're running both pages/ and app/ side by side. If you haven't started an incremental migration, the client router filter is not relevant.
  • You have a basePath set. The duplication bug only shows up when basePath is in play.
  • Only some links misbehave, and the broken ones trigger a full page reload.
  • The failing URL has the path prefix doubled up. Pop open your browser's Network tab and watch where the navigation actually lands.
  • Setting appDir: false makes the problem disappear. The bloom filter only gets generated when the App Router is turned on, so switching it off removes the filter (and the bug) along with it.

The workaround (and the potential fix)

The workaround is to set experimental.clientRouterFilter to false in your next.config.js:

// next.config.js module.exports = { basePath: '/plan/configuration', experimental: { clientRouterFilter: false, }, };

This disables the bloom filter entirely. The downside is that the router can no longer automatically detect when to do a hard navigation between Pages and App Router routes. If you have routes in both directories, you may need to handle cross-router transitions yourself (e.g., using <a> tags instead of <Link> for routes that cross the boundary).

If you want to keep the filter but reduce false positives, you can adjust the allowed rate:

// next.config.js module.exports = { experimental: { clientRouterFilterAllowedRate: 0.001, // default is 0.01 }, };

Just be aware that lowering the false positive rate increases the size of the bloom filter in your client bundle. There's a tradeoff between accuracy and bundle size.

As for the official fix for this issue, it was supposed to be handled through this PR but I've seen this issue happen on later versions of Next.js.

Conclusion

If you're incrementally migrating to the App Router and something feels off with navigation (unexpected full-page reloads, occasional 404s, URLs that look subtly wrong), check the client router filter. It might be causing the issue.

Unfortunately, clientRouterFilter is barely documented in the official Next.js docs, which makes it that much harder to diagnose. Hopefully this post saves you some of the debugging time it cost me.