Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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:

PriorityTriggerWhat ships
1Link in the viewportStatic: the whole route. Dynamic: only up to a loading.js boundary
2Hover or touch (user intent)Promotes an already-queued link ahead of viewport-only ones
3Newer visible linksReplace older queued ones — the queue isn't unbounded
4Links scrolled off-screenDropped 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 routeDynamic route
Prefetched at all?Yes, the full routeOnly if a loading.js boundary exists for it
What's cachedThe whole pageThe layout down to the first loading boundary
Client cache lifetimeUntil the app reloadsOff by default (configurable via staleTimes)
Cost of a clickNone — already in the browserA 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 wantUse
Default behaviour for a normal page linkNothing — plain <Link href="...">, prefetches on viewport entry
Stop prefetching a specific link (large list, infinite scroll)prefetch={false}
Prefetch only once the user shows intentprefetch={active ? null : false} with an onMouseEnter that flips active
A layout or page has a side effect that must not run on prefetchMove 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

SymptomCauseFix
An API route's counter/log increments far more than real usageThe route's URL is linked through next/link, so viewport prefetch calls itRender 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 pagetrackPageView() (or similar) called in a page/layout bodyMove 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 scrollEvery link in the viewport prefetches by defaultSet 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-viewNo loading.js boundary exists for that routeAdd 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 tabThe request isn't from Link at all — it's a separate fetch, image, or the browser's own speculative loadingConfirm 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.

Keep reading

Tutorial10 min read

Product Filtering in Next.js: Why Filters Belong in the URL

Filters in useState cannot be shared, bookmarked, or server-rendered. How to read them from searchParams — and which filtered URLs to let Google crawl.

Read more
Tutorial11 min read

A Product Image Gallery in Next.js That Google Can Actually See

Virtualizing a four-slide carousel removes three product images from the HTML. Mount every slide, starve the off-screen ones, and the ARIA that a carousel actually needs.

Read more
Tutorial9 min read

Product Schema in Next.js: JSON-LD That Earns Rich Results

Product needs only one of offers, review or aggregateRating — so a truthful offer qualifies you without inventing reviews. The shape, the validation, the manual-action trap.

Read more