Next.js Prefetch: What Fires on Viewport Entry, Not Just on Click
Link prefetches on scroll into view, before anyone clicks. This storefront keeps one href out of Link entirely because a prefetch there would burn a buyer's download quota.
Next.js prefetches every <Link> in the viewport automatically, in production, before anyone clicks — no configuration required. That's the whole feature for a static page. The part almost no tutorial covers is what happens when the href you're linking to isn't a page at all, but an API route with a side effect: prefetching doesn't know the difference, and this codebase has an href it deliberately keeps out of Link's reach because of exactly that.
The short answer
next/link prefetches automatically once a link enters the viewport in production — static routes fetch the full page, dynamic ones wait for a loading.js boundary if one exists. Disable it per link with prefetch={false}, or defer it to hover with prefetch={active ? null : false} plus an onMouseEnter handler. None of that matters if the href isn't a page — an API route that performs a write on GET will run that write on prefetch, before the visitor ever clicks, and no prefetch prop fixes that; the href has to stop being a Link at all.
What "automatic" actually schedules
Next.js's own prefetching guide (node_modules/next/dist/docs/01-app/02-guides/prefetching.md in this repo's installed copy) documents a small priority queue, not a blanket fetch-everything:
| Priority | Trigger | What ships |
|---|---|---|
| 1 | Link in the viewport | Static: the whole route. Dynamic: only up to a loading.js boundary |
| 2 | Hover or touch (user intent) | Promotes an already-queued link ahead of viewport-only ones |
| 3 | Newer visible links | Replace older queued ones — the queue isn't unbounded |
| 4 | Links scrolled off-screen | Dropped from the queue entirely |
The load-bearing detail: this fires on viewport entry, not on click and not even reliably on hover — a <Link> inside a card grid that scrolls into view gets its target's HTML and RSC payload fetched immediately, in the background, whether or not anyone ever touches it.
The defect this codebase specifically avoids
This storefront's Button atom renders every CTA as a next/link — except when the href is an internal API route:
// src/components/atoms/Button.tsx
/**
* The test is not "starts with a slash". `/api/download` is an internal URL
* that must stay a plain anchor: Link's prefetcher issues a GET at the href,
* and a GET there is the download — it writes a `download_events` row against
* the buyer's hourly limit (see src/lib/download.ts). Prefetching a buyer's
* download budget away on hover is worse than the full navigation it saves.
*/
function isRoutable(href: string, external: boolean): boolean {
return !external && href.startsWith("/") && !href.startsWith("/api/");
}
The reason this matters more than a wasted network request: src/lib/download.ts enforces a real, durable rate limit —
// src/lib/download.ts
export const RATE_LIMIT_MIN_PER_HOUR = 30;
export const RATE_LIMIT_PULLS_PER_ENTITLED_EDITION = 3;
— recorded atomically via a record_download_within_limit RPC every time /api/download receives a request. If that href were ever rendered through next/link, a card merely scrolling into view would count as a pull against a buyer's 30-per-hour budget, with no click involved. Next.js's own troubleshooting guide calls this out in the abstract — "Triggering unwanted side-effects during prefetching," listing analytics calls fired from a layout as the example — but a page-view side effect is a metrics nuisance. A metered, audited download endpoint is a broken feature: a buyer scrolling past their own dashboard could silently burn the budget meant for their next real download. isRoutable doesn't set prefetch={false} on this link; it keeps /api/download out of Link entirely, because the fetch a prefetch prop can't distinguish "route to a page" from "trigger the download" for a URL that is, itself, the action.
Static vs. dynamic: prefetching fetches different amounts
The scheduler above decides when a link prefetches. A separate axis — whether the target route is static or dynamic — decides how much:
| Static route | Dynamic route | |
|---|---|---|
| Prefetched at all? | Yes, the full route | Only if a loading.js boundary exists for it |
| What's cached | The whole page | The layout down to the first loading boundary |
| Client cache lifetime | Until the app reloads | Off by default (configurable via staleTimes) |
| Cost of a click | None — already in the browser | A server round-trip, streamed after the shell |
This matters for capacity planning as much as UX: a mostly-static site gets its entire prefetch benefit for free, while a site with many dynamic routes either needs loading.js boundaries everywhere or accepts that most navigations still round-trip to the server regardless of prefetching. This storefront's own build reports 452 of 460 routes as static — the full breakdown is its own post — so the overwhelming majority of <Link>s here get the full-route prefetch, and the 8 dynamic routes (the dashboard, the auth forms, two API routes) are exactly the ones a rate-limited or session-dependent endpoint would need to be, prefetching or not.
Choosing the right lever
| You want | Use |
|---|---|
| Default behaviour for a normal page link | Nothing — plain <Link href="...">, prefetches on viewport entry |
| Stop prefetching a specific link (large list, infinite scroll) | prefetch={false} |
| Prefetch only once the user shows intent | prefetch={active ? null : false} with an onMouseEnter that flips active |
| A layout or page has a side effect that must not run on prefetch | Move the side effect into a useEffect in a Client Component, not the page/layout body |
| The href is an action, not a route (a download, a mutation) | Don't route it through Link at all — render a plain <a>, as Button's isRoutable does for /api/download |
The first four rows are all prefetch-prop tuning for pages that are genuinely pages. The fifth is a different problem: no value of prefetch makes an endpoint safe to fetch speculatively, because the fetch itself is the side effect.
Why this codebase never reaches for manual prefetch
Next.js also exposes router.prefetch() from useRouter, for warming a route outside a link's own viewport-entry trigger — on a custom hover handler, in response to an analytics signal, or ahead of an anticipated multi-step flow. grep -rn "router.prefetch" src/ on this repository returns nothing, and the reason is the same one behind the 452-static-route number above: manual prefetch earns its keep when the automatic trigger (viewport entry) is too late or too narrow for how a user actually reaches a page — an infinite-scroll list where links never sit in the viewport long enough, or a wizard where step 3 should start loading the moment step 1 renders. A storefront whose navigation is mostly plain <Link>s in static card grids has neither shape: viewport-entry prefetching is already early enough, since a product card sits in view for as long as someone is deciding whether to click it. Reaching for router.prefetch() here would be solving a problem the default scheduler doesn't have.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| An API route's counter/log increments far more than real usage | The route's URL is linked through next/link, so viewport prefetch calls it | Render that href as a plain <a>, or route the action through a form/Server Action instead of a GET link |
| Analytics fires before a user visits a page | trackPageView() (or similar) called in a page/layout body | Move it into a useEffect inside a Client Component — prefetch renders the module, it doesn't run effects |
| A long list of links feels sluggish or over-fetches on scroll | Every link in the viewport prefetches by default | Set prefetch={false} on list items, and optionally re-enable on hover with prefetch={active ? null : false} |
| A dynamic route prefetches its full data on scroll-into-view | No loading.js boundary exists for that route | Add one — dynamic routes without it fetch to the page, not just a shell |
Disabling prefetch doesn't stop a request you're seeing in the network tab | The request isn't from Link at all — it's a separate fetch, image, or the browser's own speculative loading | Confirm the initiator in devtools before assuming it's Next's prefetcher |
Frequently asked questions
Does Next.js prefetch on hover or on scroll into view? Scroll into view, by default, in production — that's priority 1 in the scheduler. Hover (or touch) is priority 2, and mainly promotes a link that's already queued rather than being the trigger on its own.
Why would I ever turn prefetching off?
Two reasons: resource usage on a page with many links (an infinite-scroll list prefetching dozens of routes nobody visits), or correctness — a link whose target performs a side effect on GET that must only run on an actual visit or click.
Is prefetch={false} enough to make an action-triggering URL safe to link?
No. prefetch={false} stops the automatic prefetch, but the link is still a normal navigable <Link> — a user could still trigger the GET by any means Next's router uses to navigate. If the href's GET performs a write, the fix is to stop using Link for it, not to tune its prefetch prop.
Does disabling prefetch site-wide fix everything? It trades away the feature's actual benefit — instant-feeling navigation — for a problem that's usually scoped to one or two hrefs. This codebase prefetches normally everywhere except the one internal endpoint that isn't a page, which keeps the benefit for the other ~460 routes.
Templates in this post
ASoc Guard, ASoc Haven and ASoc Hearth all ship the same Button atom, so every internal CTA already prefetches correctly out of the box — and stays a plain anchor on the one kind of href that shouldn't.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
