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:

  1. Missing cacheGroups isolation — Webpack’s default defaultVendors group can absorb page-specific modules, producing a chunk that serves multiple routes but is referenced by none in the manifest.
  2. Undeclared route boundaries — Without an explicit pages or app cache group, modules under pages/ and app/ participate in the generic chunking algorithm and end up in unpredictably named shared chunks.
  3. 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.

Prefetch manifest alignment: correct vs broken chunk mapping Two columns comparing correct chunk isolation (manifest matches chunks) against broken shared merging (manifest cannot predict chunks). Isolated chunks (correct) pages/dashboard pages/settings chunk.dashboard.js chunk.settings.js Manifest: exact match Prefetch hits cache on navigate Merged chunk (broken) pages/dashboard pages/settings chunk.shared-abc123.js Manifest: stale or missing chunk reference Router fetches chunk at click time

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.

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>

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:

  1. Build and start:

    npx next build
    npx next start
  2. 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
  3. 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.
  4. Run bundle analysis:

    ANALYZE=true npm run build

    Open the generated report. Verify that dashboard and settings pages do not share a chunk that should be route-specific.

  5. Measure INP on route transition:

    • In DevTools > Performance, click Record, trigger a route transition, stop recording.
    • Find the routeChange event. Confirm total duration from click to paint is under 200 ms after the fix (baseline: 300–600 ms without isolated chunks).
  6. CI validation:

    npx lhci autorun

    Target: 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.