Next.js 13 App Router Link Prefetching Flooded Our Client API
A production incident where Next.js 13 App Router prefetching flooded our client API with thousands of empty JSON requests per session, and the targeted fix we shipped.
Author
When Next.js 13 App Router Prefetching Flooded Our Client API
The Incident: A Client Site Drowning in Prefetch Requests
The Setup: E-commerce Product Listing Page
Last month we were migrating a client's e-commerce product listing page from the Pages Router to the Next.js 13 App Router. The page renders a grid of 48 products, each wrapped in a <Link> to its detail page. We upgraded to Next.js 13.4.5, switched to the App Router, and deployed to staging.
The page looked fine. Clicks were fast. But our API gateway started throwing 429s within minutes of the first internal tester opening the page.
What We Observed in Production Logs
Our API logs showed a spike in GET /_next/data/.../product/[id].json requests. Each product link was firing a prefetch request the moment it entered the viewport. With 48 links per page and testers scrolling quickly, we saw 200+ prefetch requests from a single session in under 10 seconds.
The payloads were all empty objects: {}. No useful data. Just noise.
The Smoking Gun: Thousands of Requests per User Session
A single tester session generated over 1,200 prefetch requests. Our API rate limiter started rejecting them. The client's backend engineer called it a self-inflicted DDoS.
We confirmed the behavior in Chrome DevTools Network tab. Every <Link> with prefetch enabled (the default) was hitting the data route on viewport entry. The Next.js prefetching guide confirms this: "By default, Next.js prefetches routes based on the links in your application code."
What We Tried First (And What Failed)
Disabling All Prefetching with prefetch={false}
Our first instinct was to kill prefetching entirely. We added prefetch={false} to every <Link> in the product grid.
<Link href={`/product/${product.id}`} prefetch={false}>
{product.name}
</Link>
This reduced the request count, but GitHub Discussion #44596 users reported the same issue: "next still issues all the empty data requests on every hover." We confirmed it. Hovering over a link still triggered a prefetch request even with prefetch={false}.
Rolling Our Own Link Wrapper
We built a custom NoPrefetchLink component that wrapped <Link> and suppressed all prefetch behavior using router.prefetch() overrides.
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function NoPrefetchLink({ href, children, ...props }) {
const router = useRouter();
const handleClick = (e) => {
e.preventDefault();
router.push(href);
};
return (
<a href={href} onClick={handleClick} {...props}>
{children}
</a>
);
}
This worked, but we lost client-side transition benefits. Navigation became full page reloads. Not acceptable for an e-commerce experience.
The Middleware Misdirection
We suspected our middleware was the culprit. We had a middleware that rewrites URLs and injects headers. Discussion #44596 mentioned: "set config.experimental.middlewarePrefetch = 'strict' to avoid this behavior."
We added it to next.config.js:
module.exports = {
experimental: {
middlewarePrefetch: 'strict',
},
};
No change. The empty JSON requests kept flowing.
The Working Fix: Targeted Prefetching with Real Commands
Configuring next.config.js for Controlled Prefetching
We discovered that Next.js 13.4+ supports partial prefetching via the partialPrefetching config. This switches from all-or-nothing prefetching to per-route App Shell prefetching.
// next.config.js
module.exports = {
experimental: {
partialPrefetching: true,
},
};
With partial prefetching enabled, Next.js prefetches the route's App Shell once per route, not per link. A page with 48 links to the same product detail route makes one prefetch request instead of 48.
Using router.prefetch() for Hover-Based Loading
We replaced automatic viewport prefetching with hover-based prefetching using router.prefetch().
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function ProductLink({ product }) {
const router = useRouter();
const handleMouseEnter = () => {
router.prefetch(`/product/${product.id}`);
};
return (
<Link
href={`/product/${product.id}`}
onMouseEnter={handleMouseEnter}
prefetch={false}
>
{product.name}
</Link>
);
}
This defers prefetching until the user shows intent by hovering. Combined with partial prefetching, we cut prefetch requests by 90%.
Implementing loading.tsx Boundaries for Dynamic Routes
For dynamic routes like /product/[id], we added a loading.tsx file to enable partial prefetching.
// app/product/[id]/loading.tsx
export default function Loading() {
return (
<div className="p-6">
<div className="animate-pulse">Loading product...</div>
</div>
);
}
The Next.js linking guide recommends this: "We recommend adding loading.tsx to dynamic routes to enable partial prefetching, trigger immediate navigation, and display a loading UI while the route renders."
Pitfalls We Would Warn an Intern About
Prefetching Dynamic Segments Without generateStaticParams()
Issue #47981 users found that dynamic segments without generateStaticParams() get prefetched but refetched on navigation. The cache is invalidated during hard navigation.
Always add generateStaticParams() to dynamic routes you want to prefetch effectively.
Cache Invalidation During Hard Navigations
Dynamic routes that don't match the current route's dynamic segments trigger hard navigations. During a hard navigation, "the cache is invalidated and the server refetches data and re-renders the changed segments."
This means prefetching a dynamic route is wasted if the user navigates there directly.
Middleware Prefetch Headers Stripping Cache-Control
Discussion #44596 users reported that prefetch responses for page.json files are stripped of cache-control headers, even when getServerSideProps is used. This can cause caching services to cache empty prefetch responses.
Check your CDN and middleware for header stripping on _next/data routes.
What We Would Do Differently Next Time
Audit Link Density Before Upgrading to App Router
Before migrating, we should have audited how many <Link> components existed per page. A product grid with 48 links is a prefetch storm waiting to happen.
Use partialPrefetching Config from the Start
We should have enabled partialPrefetching during the initial migration instead of discovering it after the incident.
Monitor Network Tab for Empty JSON Payloads Early
The empty {} payloads were visible in DevTools from the first test. We should have caught this before staging deployment.
The Fix in Production
After shipping the targeted prefetching fix, our API gateway stopped throwing 429s. Prefetch requests dropped from 1,200 per session to fewer than 10. The product grid still feels instant on hover, and client-side transitions work as expected.
We documented the fix in our internal runbook and added a lint rule to flag <Link> components without explicit prefetch props in high-density areas.
The lesson: Next.js prefetching is powerful, but it assumes you want to prefetch everything. In high-link-density scenarios, you need to take control.
Sources:
- Guides: Prefetching | Next.js
- linking-and-navigating | Next.js
- link prefetch in next 13 /pages - constantly pre-fetching empty data · Discussion #44596
- Prefetch for links not working as expected with App Router · Issue #47981
- fix app router prefetch deduping · PR #68642
- <Link> prefetching behavior · Discussion #60581
- How Next.js Prefetching Really Works (and Where It Fails)
Sources
Related reading
Enjoyed this article?
Back to Blog


