The Problem
Last month, our team started getting complaints that our Next.js application felt sluggish. Users were experiencing slower navigation between pages, even though we hadn't made any major changes recently. The app was noticeably slower in production environments.
So I started investigating this performance regression and what I discovered was a subtle but impactful issue with how Next.js handles prefetching for dynamic routes.
The Investigation
Our application uses Next.js 15 with App Router, and we were using the standard Link
component which handles prefetching automatically. Initially, this seemed fine—prefetching should make navigation faster, not slower. So why were we seeing the opposite?
The breakthrough came when I examined our recent routing changes. We had recently implemented internationalization by nesting all our routes under a dynamic locale segment:
Before: /about, /products, /contact
After: /[locale]/about, /[locale]/products, /[locale]/contact
This seemingly innocent change had a massive impact on our app's performance, and here's why.
Understanding Next.js Prefetching Behavior
According to the Next.js documentation, Next.js handles prefetching differently for static and dynamic routes:
Route Type | Prefetched | What Gets Cached |
---|---|---|
Static Routes | Yes, full route | Entire page until app reload |
Dynamic Routes | No, unless loading.js present |
Only layout shell for 30 seconds |
The key insight: When you make routes dynamic (like adding [locale]
), Next.js can no longer prefetch the full page content automatically.
As the Next.js documentation explains: "By skipping or partially prefetching dynamic routes, Next.js avoids unnecessary work on the server for routes the users may never visit. However, waiting for a server response before navigation can give the users the impression that the app is not responding."
This is exactly what we were experiencing—our users felt like the app wasn't responding because navigation required server responses instead of using prefetched content.
The Root Cause
By nesting all our pages under /[locale]/
, we inadvertently converted every single page in our application from static to dynamic. This meant:
- No automatic prefetching of page content
-
Only layout prefetching when
loading.js
is present - Slower navigation as pages had to be fetched on-demand
The Solution
I implemented a comprehensive approach to restore fast navigation:
1. Changed from Opt-Out
to Opt-In
Prefetching Strategy
First, we fundamentally changed our prefetching approach. By default, Next.js uses an opt-out
strategy where prefetching is enabled automatically (prefetch=null
). However, given our dynamic routing structure and the performance issues it caused, we switched to an opt-in
strategy.
Understanding the prefetch prop values is crucial here:
-
"auto"
ornull
(default): Prefetch behavior depends on whether the route is static or dynamic -
true
: The full route will be prefetched for both static and dynamic routes -
false
: Prefetching will never happen both on entering the viewport and on hover
So, we created a wrapper component that disables prefetching by default:
// components/Link.jsx - Our custom Link wrapper
import NextLink from 'next/link'
export default function Link({ prefetch = false, ...props }) {
return <NextLink prefetch={prefetch} {...props} />
}
This approach gave us granular control over which pages should be prefetched, rather than relying on Next.js's automatic behavior which wasn't working effectively for our dynamic routes.
2. Explicit Prefetching for High-Traffic Pages
For our most frequently accessed pages, we enabled explicit prefetching by setting prefetch={true}
on the Link components.
// Before (using default Next.js Link)
import Link from 'next/link'
<Link href="/en/products">Products</Link>
// After (using our custom wrapper with explicit prefetch)
import Link from '@/components/Link' // Our custom wrapper
<Link href="/en/products" prefetch={true}>Products</Link>
Important: Prefetching only works in production, so you won't see the performance benefits during development.
3. Added Loading States
I created loading.js
files for our dynamic route segments to enable layout prefetching:
// app/[locale]/loading.js
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
)
}
The Results
The impact was immediate and dramatic:
- Navigation felt instant again ⚡
- Performance metrics improved significantly 📊
- Better user experience across the entire app 🎉
Additional Optimizations
On top of the core fixes that resolved our immediate performance issues, we implemented several additional optimization strategies to further enhance navigation speed and reduce unnecessary network overhead. These techniques help you fine-tune prefetching behavior for different types of pages and user interactions:
Use Explicit Prefetching Strategically
// High-traffic pages
<Link href="/[locale]/checkout" prefetch={true}>Checkout</Link>
// Lower-priority pages
<Link href="/[locale]/terms" prefetch={false}>Terms</Link>
Implement Comprehensive Loading States
// Create loading.js at each dynamic segment level
app/[locale]/loading.js
app/[locale]/products/loading.js
app/[locale]/products/[id]/loading.js
Optimize Data-Heavy Pages with Hover-Based Prefetching
For pages displaying large datasets (like record tables), automatic prefetching can become a performance bottleneck. We discovered this when implementing a records table that displayed 25 items per page—Next.js was attempting to prefetch all 25 record detail pages simultaneously, causing excessive network traffic.
The Problem with Large Lists:
// This creates 25 simultaneous prefetch requests!
{records.map(record => (
<Link key={record.id} href={`/records/${record.id}`}>
{record.title}
</Link>
))}
The Solution - Hover-Triggered Prefetching:
'use client'
import Link from '@/components/Link'
import { useState } from 'react'
export function HoverPrefetchLink({ href, children }) {
const [shouldPrefetch, setShouldPrefetch] = useState(false)
return (
<Link
href={href}
prefetch={shouldPrefetch ? true : false}
onMouseEnter={() => setShouldPrefetch(true)}
>
{children}
</Link>
)
}
// Usage in your records table
{records.map(record => (
<HoverPrefetchLink key={record.id} href={`/records/${record.id}`}>
{record.title}
</HoverPrefetchLink>
))}
This approach reduces initial network overhead while still providing fast navigation for pages users actually intend to visit.
Key Takeaways
- Dynamic routes change prefetching behavior fundamentally - Even a single dynamic segment can affect your entire application's performance
- Locale-based routing has performance implications - Consider the trade-offs when implementing i18n with dynamic routes
- Explicit prefetching gives you control - Don't rely solely on automatic prefetching for critical user journeys
- Loading states are crucial for dynamic routes - They enable layout prefetching and provide better UX
- Monitor performance after routing changes - Seemingly small changes can have outsized impacts
- Optimize for your specific use case - Use hover-based prefetching for data-heavy pages to avoid unnecessary network requests
Conclusion
Performance regressions can be sneaky, especially when they stem from architectural decisions that seem beneficial on the surface. Our internationalization implementation improved the user experience for global users but inadvertently broke our prefetching strategy.
The lesson here is clear: understand how your routing architecture affects Next.js's built-in optimizations. Dynamic routes aren't inherently bad, but they require different performance strategies than static routes.
By combining explicit prefetching with proper loading states and smart optimization strategies for different page types, we were able to restore—and even improve upon—our original navigation performance while maintaining the benefits of our localized routing structure.
Top comments (0)