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

The ghost in your Next.js router: how a bloom filter can 404 your pages

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 most developers don't even know exists: the client router filter.

The problem: two routers, one app

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, this introduces a routing question: 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.

What clientRouterFilter actually is

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 says "definitely not an app route", then proceed with normal client-side navigation. No page reload.
  • If the bloom filter says "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. They'll occasionally flag 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.

Where basePath makes everything worse

Here's where the pain begins. 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 says "that might be an app route." So instead of a smooth client-side transition, Next.js decides to do a full page reload.

2. 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. In the original GitHub issue, /activities was broken while /skills worked perfectly.

How to tell if this is your problem

A few telltale signs:

  • You're using both pages/ and app/ directories. If you're not incrementally migrating, the client router filter isn't relevant to you.
  • You have a basePath configured. The basePath duplication bug specifically requires this.
  • Only some links break, and they trigger full page reloads. If every link broke, it'd be a different problem. The randomness is the signature of a bloom filter false positive.
  • The failing URL has a doubled path prefix. Open your browser's Network tab and look at where the navigation actually goes. If you see your basePath repeated twice, this is almost certainly the issue.
  • It works fine when you remove appDir: true. Since the bloom filter is only generated when the App Router is enabled, disabling it eliminates the filter entirely.

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).

On the other hand, 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 just be the ghost in your machine.

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.