Setting Up Route-Based Prefetching in Next.js
Symptom: After navigating between routes in a Next.js app, Chrome DevTools shows a chain of sequential JavaScript fetch requests — each chunk’s startTime comes after the previous chunk’s responseEnd. In the Performance tab, hydration completes 300–600 ms after the navigation click, and Interaction to Next Paint (INP) exceeds 200 ms. The Network panel may also show chunks arriving with status 200 on every visit rather than being served from the prefetch cache.
This page diagnoses the root cause and provides a reproducible fix using Webpack 5’s splitChunks, the Next.js <Link> component, and a viewport-triggered IntersectionObserver fallback.
Root Cause: Chunk Merge vs. Prefetch Map Alignment
Next.js builds a prefetch manifest at compile time that maps route paths to their required JavaScript chunks. The prefetch and preload strategies for critical routes depend on this manifest being accurate: if the runtime chunk graph diverges from the manifest, the router requests the wrong assets or falls back to a full sequential fetch at navigation time.
The mismatch occurs when Webpack 5’s splitChunks merges adjacent page modules into a single shared chunk without updating the manifest’s chunk-to-route mapping. Three concrete causes:
- Missing
cacheGroupsisolation — Webpack’s defaultdefaultVendorsgroup can absorb page-specific modules, producing a chunk that serves multiple routes but is referenced by none in the manifest. - Undeclared route boundaries — Without an explicit
pagesorappcache group, modules underpages/andapp/participate in the generic chunking algorithm and end up in unpredictably named shared chunks. - Deterministic hash collisions after source changes — Webpack 5 uses content-based hashing; a dependency change in one route can alter the hash of a shared chunk, invalidating prefetch cache entries for unrelated routes.
This sits within the broader route-based code splitting and dynamic import strategies discipline: the bundler must produce stable, route-specific chunk names that the router can anticipate.
Exact Fix: Webpack 5 cacheGroups and Link Configuration
Step 1 — Isolate page-level chunks in next.config.js
The key constraint: spread the existing splitChunks config rather than replacing it. Replacing removes Next.js’s built-in framework, lib, and commons groups, which causes vendor code to be duplicated across routes.
// Webpack 5 — next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.optimization.splitChunks = {
...config.optimization.splitChunks,
cacheGroups: {
// Preserve Next.js built-in groups
...config.optimization.splitChunks?.cacheGroups,
// Isolate modules under pages/ and app/ into named route chunks
pages: {
test: /[\\/]pages[\\/]|[\\/]app[\\/]/,
chunks: 'all',
priority: 20,
reuseExistingChunk: true,
name: false, // Let Webpack derive stable content-based names
},
},
};
return config;
},
};
module.exports = nextConfig;Setting name: false prevents two routes from colliding on a hand-coded name while still producing deterministic hashes from content.
Step 2 — App Router: default <Link> behaviour
In the App Router (Next.js 13+), <Link> prefetches static segments automatically when the link enters the viewport, and prefetches up to the nearest loading.tsx boundary for dynamic segments. No additional configuration is needed for static routes; the fix above ensures the prefetched chunk names match what the router requests.
// App Router — no prefetch prop needed for static routes
import Link from 'next/link';
export default function Nav() {
return (
<nav>
<Link href="/dashboard">Dashboard</Link>
<Link href="/settings">Settings</Link>
</nav>
);
}To opt a specific link out of prefetching (for rarely visited routes that would waste bandwidth):
<Link href="/audit-log" prefetch={false}>Audit Log</Link>Step 3 — Pages Router: viewport-triggered PrefetchLink
In the Pages Router, <Link> prefetches on mouseenter in production. For content-heavy pages where links are below the fold, swap in a viewport observer so the prefetch fires before the cursor arrives:
// Webpack 5 / Pages Router — components/PrefetchLink.tsx
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
interface PrefetchLinkProps {
href: string;
children: React.ReactNode;
threshold?: number;
}
export function PrefetchLink({
href,
children,
threshold = 0.1,
}: PrefetchLinkProps) {
const router = useRouter();
const ref = useRef<HTMLAnchorElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
router.prefetch(href);
observer.disconnect(); // one-shot: prefetch once per mount
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [href, router, threshold]);
// Disable hover prefetch; the observer handles it earlier
return (
<Link ref={ref} href={href} prefetch={false}>
{children}
</Link>
);
}Using prefetch={false} on the underlying <Link> prevents the hover trigger from firing a duplicate request when the observer has already queued the fetch.
Step-by-Step Verification
Run these steps in order after deploying the config change:
-
Build and start:
npx next build npx next start -
Confirm chunk naming: Check that route-specific chunks appear under
_next/static/chunks/pages/with stable filenames:curl -I http://localhost:3000/_next/static/chunks/pages/dashboard.js # Expect: HTTP/1.1 200 OK, Cache-Control: public, max-age=31536000, immutable -
Inspect prefetch in DevTools:
- Open Chrome DevTools > Network, filter by
JS. - Hover a
<Link>(Pages Router) or scroll the link into view (App Router). - Confirm a request appears with Type: prefetch or Initiator: next/link and Status: 200.
- Navigate to the route. The same chunk should now show Size: (disk cache) and transferSize: 0.
- Open Chrome DevTools > Network, filter by
-
Run bundle analysis:
ANALYZE=true npm run buildOpen the generated report. Verify that
dashboardandsettingspages do not share a chunk that should be route-specific. -
Measure INP on route transition:
- In DevTools > Performance, click Record, trigger a route transition, stop recording.
- Find the
routeChangeevent. Confirm total duration from click to paint is under 200 ms after the fix (baseline: 300–600 ms without isolated chunks).
-
CI validation:
npx lhci autorunTarget: INP < 200 ms (Good threshold), no render-blocking scripts on transition.
Edge Cases and Gotchas
Dynamic segments bypass the static prefetch manifest
Routes like /dashboard/[id] have no static URL, so Next.js excludes them from the compile-time prefetch manifest. The <Link> component cannot prefetch these routes automatically. When the user’s recent item IDs are known at runtime — from a session store or API response — call router.prefetch() explicitly:
// Pages Router — prefetch known dynamic routes after data loads
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export function RecentItems({ items }: { items: { id: string }[] }) {
const router = useRouter();
useEffect(() => {
// Prefetch each known item route in the background
items.forEach(({ id }) => router.prefetch(`/dashboard/${id}`));
}, [items, router]);
return (/* render list */);
}In the App Router, router.prefetch() from next/navigation accepts the same resolved URL.
next/dynamic with ssr: false breaks the prefetch chain
When a route component uses next/dynamic with ssr: false, the chunk for that component is excluded from the server-rendered HTML and from the prefetch manifest for that route. The browser only discovers the chunk when the client executes the dynamic import(). To prevent a waterfall when the route loads, add a webpackPrefetch magic comment:
// Webpack 5 — src/components/HeavyChart.tsx
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(
() => import(/* webpackPrefetch: true */ './HeavyChartInner'),
{
ssr: false,
loading: () => <div style={{ height: 400 }} aria-label="Loading chart" />,
}
);webpackPrefetch: true causes Webpack to emit a <link rel="prefetch"> for the inner chunk in the parent bundle, so the browser queues it during idle time before the user reaches the chart.
Hash invalidation after unrelated dependency changes
If a shared utility module changes, its content hash changes, and any chunk that includes it gets a new filename. This silently invalidates prefetch cache entries for routes that depend on that chunk — even if the route’s own code did not change. Mitigate by isolating volatile utilities into their own named cache group:
// Webpack 5 — next.config.js addition to cacheGroups
utils: {
test: /[\\/]src[\\/]utils[\\/]/,
chunks: 'all',
priority: 15,
reuseExistingChunk: true,
name: 'utils', // stable name; only invalidated when utils/ changes
},This prevents a single utility change from cascading hash invalidations across route chunks, keeping prefetch cache hit rates high across deployments.
FAQ
Why does Next.js prefetch silently fail on dynamic routes?
Dynamic routes like /dashboard/[id] have no statically known URL, so Next.js cannot include them in the automatic prefetch manifest. You must call router.prefetch() explicitly with the resolved URL when the target ID is known.
Does merging the splitChunks config instead of replacing it matter?
Yes. Replacing the entire splitChunks object removes Next.js’s built-in cacheGroups (framework, lib, commons), which can cause duplicate vendor code across routes. Always spread the existing config and add your groups on top.
How do I confirm a prefetch request actually populated the browser cache?
In Chrome DevTools Network tab, look for the chunk request with a Prefetch initiator and status 200. When the route is navigated, the same chunk request shows transferSize: 0 and a (disk cache) entry, confirming the prefetch hit.
Related
- Prefetch and Preload Strategies for Critical Routes — parent page covering
rel=prefetch,rel=modulepreload, and Vite 5 preload injection patterns - Route-Based Code Splitting & Dynamic Import Strategies — foundational architecture for route-level chunk boundaries
- Preventing Waterfall Requests with Dynamic Import Maps — adjacent failure mode: sequential chunk discovery in SPAs
- Implementing Route-Level Code Splitting in SPAs — React lazy/Suspense and route boundary setup that prefetching builds on