How I Finally Fixed the Slow Navigation in Next.js App Router (And You Can Too!)
If you're reading this, chances are you've experienced that annoying lag when clicking links in your Next.js App Router application. You know what I'm talking about - that frustrating moment when you click a navigation link and... nothing happens. You wait. Still nothing. Then suddenly, boom - the page changes.
Yeah, I've been there too. And honestly? It was driving me crazy.
The Problem That Kept Me Up at Night
I recently migrated one of my projects to Next.js 15 with the App Router, and while I loved all the new features - server components, improved data fetching, better performance - there was this one thing that kept bugging me: the navigation felt sluggish.
Coming from the world of SPAs (Single Page Applications), I was used to instant feedback. Click a link, see it highlighted immediately, page transitions smooth as butter. But with the App Router, there was this weird limbo period between clicking a link and actually seeing any visual feedback.
I started Googling. "Next.js slow navigation", "App Router navigation lag", "Next.js navigation feels slow" - you name it, I searched it. And you know what? I wasn't alone. Tons of developers were complaining about the same issue.
Why Does This Happen?
Here's the thing: the App Router relies heavily on server-side rendering (SSR) and static site generation (SSG). While this is great for performance and SEO, it means Next.js has to wait for the server to process the request before updating the UI.
During this waiting period:
- The link doesn't show an active state
- The current content just sits there, looking stale
- Users (like me) keep clicking, wondering if the app is broken
- The experience feels janky and unresponsive
Even worse, the navigation hooks like usePathname and useSearchParams only update after the navigation completes. So you can't even use them to show a loading state or highlight the active link immediately.
The Search for a Solution
I tried different approaches:
- Added loading.js files (helped, but didn't solve the instant feedback issue)
- Experimented with Suspense boundaries (same story)
- Attempted client-side state management with onClick events (worked, but had edge cases like Cmd+Click to open in new tab)
Nothing felt quite right. Until I stumbled upon Next.js 15.3 release notes and saw two game-changing features:
onNavigateevent - fires when navigation starts, only on client-sideuseOptimistichook - allows optimistic UI updates
And that's when it clicked. I could combine these to create instant, snappy navigation!
The Solution That Actually Works
Here's what I built, and trust me, it's simpler than you might think.
Step 1: Create a Navigation Context
First, I created a context to manage the optimistic navigation state across my entire app:
// contexts/OptimisticNavigationContext.tsx "use client"; import { usePathname } from "next/navigation"; import { createContext, ReactNode, useContext, useOptimistic } from "react"; type OptimisticNavigationContextType = { isNavigating: boolean; optimisticPathname: string; setOptimisticPathname: (pathname: string) => void; }; const OptimisticNavigationContext = createContext< OptimisticNavigationContextType | undefined >(undefined); export const OptimisticNavigationContextProvider = ({ children, }: { children: ReactNode; }) => { const pathname = usePathname(); const [optimisticPathname, setOptimisticPathname] = useOptimistic( pathname, (_, action: string) => action ); return ( <OptimisticNavigationContext.Provider value={{ isNavigating: pathname !== optimisticPathname, optimisticPathname, setOptimisticPathname, }} > {children} </OptimisticNavigationContext.Provider> ); }; export const useOptimisticNavigation = () => { const context = useContext(OptimisticNavigationContext); if (!context) { throw new Error( "useOptimisticNavigation must be used within a OptimisticNavigationContextProvider" ); } return context; };
The magic here is useOptimistic. It tracks two states:
pathname- the actual current path (from Next.js)optimisticPathname- where we think we're going
When they differ, we know navigation is in progress!
Step 2: Wrap Your App
Next, I wrapped my entire app with this context provider in the root layout:
// app/layout.tsx import { OptimisticNavigationContextProvider } from '@/contexts/OptimisticNavigationContext'; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <OptimisticNavigationContextProvider> {children} </OptimisticNavigationContextProvider> </body> </html> ); }
Step 3: Update Your Navigation Links
This is where the real magic happens. In my Header component, I updated the links to use the new onNavigate event:
// components/Header.tsx "use client"; import Link from 'next/link'; import { startTransition } from 'react'; import { useOptimisticNavigation } from '@/contexts/OptimisticNavigationContext'; export default function Header() { const { optimisticPathname, setOptimisticPathname } = useOptimisticNavigation(); return ( <nav> <Link href="/about" className={optimisticPathname === '/about' ? 'active' : ''} onNavigate={() => startTransition(() => setOptimisticPathname('/about'))} > About </Link> <Link href="/blog" className={optimisticPathname === '/blog' ? 'active' : ''} onNavigate={() => startTransition(() => setOptimisticPathname('/blog'))} > Blog </Link> {/* More links... */} </nav> ); }
Important gotcha I discovered: You MUST wrap setOptimisticPathname in startTransition(). Otherwise, you'll get an error about optimistic updates happening outside a transition. Learned that one the hard way!
Step 4: Add Loading States (Bonus!)
Want to show a loading indicator while navigating? Super easy now:
// components/NavigationWrapper.tsx "use client"; import { useOptimisticNavigation } from '@/contexts/OptimisticNavigationContext'; export default function NavigationWrapper({ children, className = '' }: { children: React.ReactNode; className?: string; }) { const { isNavigating } = useOptimisticNavigation(); return ( <div className={`transition-opacity duration-200 ${ isNavigating ? 'opacity-50' : 'opacity-100' } ${className}`} > {children} </div> ); }
Wrap any component with this, and it'll fade out during navigation. Clean and simple.
The Results
After implementing this solution, the difference was night and day:
✅ Instant feedback - Links highlight immediately when clicked
✅ Better UX - Users know their click registered
✅ Loading states - Can show spinners or fade effects anywhere
✅ Handles edge cases - Works with Cmd/Ctrl+Click, middle mouse button, etc.
✅ Feels like an SPA - Fast, responsive, exactly what I wanted
Important Notes & Gotchas
1. Always use startTransition
Don't forget to wrap your optimistic updates in startTransition(), or React will yell at you.
2. This works with pathnames only
If you're using query parameters in your navigation, you'll need to extend the solution to track those too.
3. Requires Next.js 15.3+
The onNavigate event is only available in Next.js 15.3 and above. Make sure you're updated!
4. Client components only
The useOptimistic hook and onNavigate event only work in client components. But that's fine - just mark your navigation components with "use client".
Wrapping Up
Honestly, this solution has been a game-changer for my Next.js projects. The navigation finally feels as snappy as it should, and my users have stopped complaining about the "broken" links.
If you're struggling with slow navigation in Next.js App Router, give this approach a try. It might just save your sanity like it saved mine.
Got questions or improvements? Drop them in the comments below. And if this helped you, consider sharing it with other developers fighting the same battle!
Happy coding! 🚀
P.S. - Big shoutout to the Next.js team for adding the onNavigate event. This is exactly the kind of DX improvement that makes framework updates exciting.