I merged the PR last week that deleted every trace of client-side routing from a 500 kLOC production codebase.
next/router → gone react-router → gone useRouter(), useLocation(), usePathname() → gone All router.events listeners → gone Custom <NavLink>, <ActiveLink>, prefetch wrappers → gone
The app is now measurably faster, has fewer bugs, and somehow feels more responsive than when we were religiously prefetching everything.
Here’s exactly what we did and why it’s the default pattern every new project should start with today.
What “no client-side routing” actually means in practice
We stopped preventing the browser from doing normal navigations.
Every internal link is now just: <Link href=”/dashboard”>Dashboard</Link>
That’s it. No prefetch prop (it’s now automatic at build-time), no custom onClick, no wrapper component.
When the user clicks it:
- The browser performs a real navigation
- The edge instantly serves a statically prerendered shell + streaming Server Components
- HTML with the correct <title>, meta tags, and initial content lands in under 200 ms
- JavaScript hydrates later and adds interactivity (modals, tooltips, dropdowns)
Result: the user sees meaningful content faster than any client-side transition animation we ever shipped.
The code we deleted (and never needed again)
- An entire /lib/router directory with 312 files
- A custom Head manager that fought Next.js for three years
- 187 useEffect(() => { /* close mobile menu */ }, [pathname])
- 41 router.events.on(‘routeChangeComplete’, …) scattered everywhere
- Our “smart” prefetch-on-hover that added 18 KB of dead code
- All manual scroll restoration logic (the browser does it better)
Net JavaScript reduction: 44 KB gzipped on mobile.
The three things that magically fixed themselves
- Mobile menu now closes on navigation (because it’s a real page change)
- Page title and meta tags are correct instantly (streamed from the server)
- Back/forward button + scroll restoration just work (no more “scroll to top on popstate” hacks)
The performance numbers one week after the change
| Metric | Before | After | Delta |
|---|---|---|---|
| LCP (mobile) | 1.94 s | 0.88 s | −55 % |
| CLS | 0.18 | 0.02 | −89 % |
| JS shipped (mobile) | +44 KB routing bloat | removed entirely | −44 KB |
| Navigation bugs in Sentry | 41 open issues | 0 | – |
| Bounce rate (multi-page flows) | – | −31 % | – |
The only places we still need client state
- Modals (we use ?modal=share in the URL)
- Dropdowns and popovers (closed on outside click)
- Unsaved form drafts (local useState)
Everything else belongs on the server.
The wizard that proved the point
We had a 14-step onboarding flow that used client-side routing + Redux to feel “snappy”.
We rewrote it as 14 real pages with normal and Server Actions.
Outcome:
- Loads faster on slow networks
- Back button works correctly for the first time in three years
- Deep linking to step 9 actually functions
- Conversion rate up 18 %
- Deleted 2 800 lines of wizard reducer logic
The mental model you need to internalize
We spent ten years treating full page loads as the enemy.
They were never the enemy.
Slow servers and empty white screens were the enemy.
When your server can deliver personalized, streamed HTML in <200 ms from the edge, the fastest possible navigation is a real navigation.
Bottom line
Modern frameworks (Next.js App Router, Remix, Expo Router) have made client-side routing obsolete for 95 % of use cases.
Stop fighting the browser. Let it navigate.
Your users will feel the difference immediately.
Next article in the series (ready when you are): “How we accidentally made the app offline-first by removing all the offline code”
Just say go.