Skip to main content
ASoc
Tutorial

Next.js Link: 40 of Them, and 22 CTAs the Lint Rule Cannot See

no-html-link-for-pages reads literal hrefs on raw anchors, so it never saw this site's button atom. Plus the internal URL that must stay an anchor: /api/download.

The ASoc Team9 min read

next/link is the App Router's client-side navigation component: it renders an <a>, prefetches the target route when the link enters the viewport, and swaps the changed segments instead of reloading the document. The catch is what it does not cover. This storefront had 40 <Link> elements and 22 primary CTAs that were plain anchors, and the lint rule that is supposed to catch that could not see a single one of them.

Three things, and only the first is the one people quote.

<a href="/pricing"><Link href="/pricing">
Rendered element<a><a>
NavigationFull document request; new JS/CSS parse; React tree thrown awayFetch the changed segments, patch the tree
PrefetchNoneOn viewport entry, in production
Scroll / layout stateLostShared layouts stay mounted
Works with JS disabledYesYes — it is still an anchor with a real href

That last row is the one worth internalising before anything else here: Link is not a JavaScript replacement for a link. It renders <a href="/pricing"> into the HTML, so a crawler, a middle-click and a JS-disabled browser all behave exactly as they would without it. Everything Link adds is an enhancement on top of markup that already worked. In Next 16.2.9how that version number is chosen and when it moves — the version this site builds on, prefetch defaults to "auto": static routes are prefetched whole, dynamic ones down to the nearest loading.js boundary, and prefetching only happens in production builds.

Counted across src/ on 2026-09-07:

Count
Files importing next/link23
<Link> elements40
Raw <a href="/…"> with a literal internal path0
<Button> call sites (the CTA atom)22
…of those, whose href begins with /14

Zero raw internal anchors reads like a clean sweep. It is not, because the fifth row is where the site's most-clicked links live: the hero's "Browse templates", every pricing CTA, "Owned — view in dashboard", the 404 page's way back. All of them go through one atom, src/components/atoms/Button.tsx, which rendered this:

<a href={href} className={...} {...rest}>{children}</a>

Every one of those was a full document navigation. Click the hero CTA and the browser threw away the React tree, re-requested the document, re-parsed the JS and rebuilt the page — to move between two prerendered routes that share a header, a footer and a stylesheet.

Why the lint rule never fired

eslint-config-next's core-web-vitals preset includes @next/next/no-html-link-for-pages, which exists to catch exactly this. It did not fire once.

The rule works by reading the literal string in an href attribute on an <a> element and checking it against the pages directory. Two things defeat it here:

  1. The element is <Button>, not <a>. The rule does not follow a component into its implementation.
  2. Inside the atom, the href is {href} — a prop. There is no literal to resolve, so even if the rule looked inside, there is nothing to match.

This is the general shape of the trap: the moment you wrap your links in a design-system component, the lint rule protecting them stops working, silently, and the codebase looks cleaner than it is. The repo's own convention note ("literal internal hrefs on raw <a> may trip the rule — convert exactly those") is accurate and was fully satisfied. It just described a smaller set than the problem.

The internal URL that must stay an anchor

The obvious fix is href.startsWith("/") → <Link>. That is wrong here, and the reason is the most useful thing in this post.

One <Button> on the product page carries href={selected.href}, which resolves to:

// src/lib/downloadOptions.ts
export function downloadHref(productSlug: string, framework: string): string {
  return `/api/download?product=${encodeURIComponent(productSlug)}&framework=${encodeURIComponent(framework)}`;
}

That is an internal URL beginning with a slash. It is also a Route Handler whose GET is the download: it authorises the request, writes a download_events audit row through an atomic RPC, and counts against the buyer's hourly limit — a floor of 30 pulls an hour, scaled to the editions their entitlements actually cover.

Link prefetches by issuing a request at the href when the link enters the viewport. Pointing it at that route means the browser would fire the download endpoint on scroll, before anyone clicked anything — spending a buyer's rate-limited budget and writing audit rows for downloads that never happened. A slash is not a promise that a URL is a page.

So the atom's rule is a predicate, not a prefix test:

function isRoutable(href: string, external: boolean): boolean {
  return !external && href.startsWith("/") && !href.startsWith("/api/");
}

The 22 CTAs now split: 13 statically routable through next/link, one runtime-dependent (the pricing tier CTA, which is /templates on the free tier and #newsletter on the rest), four disabled states that render no link at all, two external (a GitHub URL, a LemonSqueezy checkout), one hash, and /api/download — deliberately still an anchor, now with a comment saying why.

Where the same decision already lived

The MDX renderer had solved this months earlier, one file over. src/mdx-components.tsx maps every <a> an article produces:

a: ({ href, children, ...props }) => {
  const isInternal = href?.startsWith("/");
  if (isInternal) return <Link href={href} className={className}>{children}</Link>;
  return <a href={href} className={className} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
}

Same test, applied to prose links rather than CTAs — and safe there, because no article links to /api/. Worth noting for anyone copying it: the internal branch drops {...props}, so an attribute set on an internal Markdown link silently disappears. It is on the list.

Which props matter, and which do not

PropUse it whenNote
hrefAlwaysAccepts a string or a URL object
prefetchRarely"auto" is right; false for a long list of links you don't expect clicks on
replaceAfter a form redirectReplaces the history entry so Back doesn't re-enter the flow
scrollRarelyDefaults to scrolling to the top on navigation
target / relOn external linksAnd then you probably want a plain <a> instead
legacyBehaviorNever in new codePages Router leftover; Link renders its own <a> now

The one that actually costs money if you get it wrong is prefetch, and it costs it in the direction above: prefetching something that is not a page.

Common mistakes

MistakeWhat happensFix
Wrapping links in a design-system componentno-html-link-for-pages goes silentRoute inside the component; audit the atom, not the call sites
href.startsWith("/") → LinkPrefetch fires at Route HandlersExclude /api/; a slash is not a page
<Link> around an external URLNo routing to do; prefetch 404sPlain <a> with rel="noopener noreferrer"
Nesting <a> inside <Link>Invalid nested anchorsLink renders the anchor itself
<Link> to a file downloadFull route prefetch of a binaryAnchor, plus download when same-origin
Assuming Link is required for SEONothing to fixBoth emit <a href>; crawlers see the same HTML

Frequently asked questions

Does using <a> instead of <Link> hurt SEO? No. Both render <a href="…"> into the prerendered HTML, so a crawler reads identical markup either way. The cost of a plain anchor is paid by the human — a full document load instead of a segment patch — not by the crawler. What does hurt is a link with no href at all, such as a <div onClick={router.push}>.

Is useRouter().push() an alternative? Only for navigation that follows something other than a click on a link — after a successful Server Action, say. push() emits no anchor, so there is nothing to middle-click, copy, prefetch, or crawl. If a user could reasonably expect to open it in a new tab, it is a link.

Should I disable prefetch to save bandwidth? Usually not. It is "auto" for a reason, and for a static route the payload is the same prerendered data the click would fetch anyway. Consider prefetch={false} on long lists where most links go unclicked — a 111-card catalogue grid is the case worth measuring — and always on any href that is not a page.

How do I catch this in a codebase I did not write? Grep for the anchor, not the route: grep -rn '<a ' src/ and read every hit, then grep for the components that render one. The lint rule covers only the intersection of "element is <a>" and "href is a literal", and a mature codebase keeps most of its links outside that intersection.

Templates where this pattern ships

ASoc Weave, ASoc Zenith and ASoc Aegis are multi-page landing templates where the CTA repeats on every section, so the routing decision above is made once in a shared button and inherited everywhere — which is exactly why it is worth getting right in the atom.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the route tree these links move between, read 27 page files, 412 URLs; for the CTA atom itself, every button here is an anchor; for what the prefetcher would have hit, gated file downloads.

Keep reading

Tutorial11 min read

Persisting UI State in localStorage Without a Hydration Mismatch

Prerendered HTML cannot know what one browser saved. An empty server snapshot, the getSnapshot cache that stops an infinite render loop, and why theme is the opposite problem.

Read more
Tutorial11 min read

Dynamic Open Graph Images in Next.js with ImageResponse

A metadata route under a dynamic segment needs its own generateStaticParams, or every card renders per request. That trap, the Satori CSS subset, fonts, and how to verify a crawler sees it.

Read more