GET request side effects: the link checker that opened 36 checkout sessions, and the Link prefetch that runs your route handler
A link checker opened 36 checkout sessions, and Next.js Link prefetch ran a GET route handler with no click. RFC 9110 on safe methods, and the fix.
Our store's checkout data said 54 checkout sessions were started between October 1 and November 5. Not one became an order; the only order the store has ever taken is a refunded test from July. Then I looked at the timestamps: 36 of the 54 were created in the same hour of the day, 15:00 UTC, which is when the site's daily SEO audit runs. The audit checks every external link with a GET, and every GET of a hosted Polar checkout link opens a new checkout session. GET request side effects like this one are easy to build and slow to notice, because the clients that trigger them are not people. While writing this up I found the same bug somewhere I did not expect: Next.js <Link> prefetch running a GET route handler on page load, with nobody clicking anything.
What a GET is allowed to do
RFC 9110, section 9.2.1, defines the safe methods, and GET is one of them:
Request methods are considered "safe" if their defined semantics are essentially read-only; i.e., the client does not request, and does not expect, any state change on the origin server as a result of applying a safe method to a target resource.
It also says who relies on that: "The purpose of distinguishing between safe and unsafe methods is to allow automated retrieval processes (spiders) and cache performance optimization (pre-fetching) to work without fear of causing harm." And it puts the duty on the server, not the client. When a URL performs an action, "the resource owner MUST disable or disallow that action when it is accessed using a safe request method", or there will be "unfortunate side effects when automated processes perform a GET on every URI reference for the sake of link maintenance, pre-fetching, building a search index, etc."
A link checker is exactly that kind of automated process. So is a prefetcher.
Measured: the link checker, the buyers and the prefetch
The store data. From Polar's API (counts only):
- October 1 to November 5, 12:00 UTC: 54 checkout sessions, 36 of them in the 15:00 UTC hour.
- November 5 to 14, after the audit stopped fetching the checkout host: 29 sessions, 0 with an email typed, 27 of them already expired on their own. They came from 26 distinct IP addresses (I counted them; I did not look at them). The hours were spread out, with 6 in the 16:00 UTC hour, the hour after the daily publish, and 6 at 22:00.
Polar's checkout object records an IP address and no user agent, so I cannot say how many of the 29 were crawlers. What I can say is that the number that means a person tried to buy, a session with an email in it, is 0.
The browser. I loaded a blog post, /chat and /pricing on production in Chromium with every request to the checkout host blocked inside the browser, so nothing reached Polar. Across 5 Buy links, scrolled into view and hovered, the page made 0 requests to that host. The Buy links are plain <a> tags, and a browser does not fetch those on its own. A session needs a click, or a client that GETs links.
Next.js prefetch. On a production build I made a test page with four links, each to something that writes a line to a log when it runs:
<Link href="/zz-prefetch-test/route-handler">GET route handler via Link</Link>
<Link href="/zz-prefetch-test/dyn-default">dynamic page via Link (default prefetch)</Link>
<Link href="/zz-prefetch-test/dyn-full" prefetch={true}>dynamic page via Link prefetch=true</Link>
<a href="/zz-prefetch-test/plain-a">dynamic page via plain a</a>
After the page loaded and sat idle for 3 seconds, with no clicks:
- The GET route handler ran. The request carried
Next-Router-PrefetchandRSCheaders, and the handler did its work anyway. - The
prefetch={true}page rendered, side effect included. - The default-prefetch page was requested, but its component did not run.
- The plain
<a>was never requested.
The run gave the same result twice, and hovering the links afterwards added nothing. Next's docs describe this behavior: prefetching "happens when a <Link /> component enters the user's viewport", and it "is only enabled in production".
Fix it on both sides
The client that fetches. Our audit now skips hosts whose links have side effects:
// Some outbound links have side effects when fetched: every GET of a Polar
// checkout link opens a new checkout session, so a daily audit would fill
// the store's checkout analytics with sessions no buyer ever started.
const SIDE_EFFECT_HOSTS = new Set(["buy.polar.sh"]);
const ext = [...new Set(pages.flatMap(({ data }) => data.extLinks))].filter(
(u) => !SIDE_EFFECT_HOSTS.has(new URL(u).host),
);
I did not switch it to HEAD instead. RFC 9110 lists HEAD as safe too, which promises nothing about what a given server does with it, and testing whether Polar opens a session on HEAD would create the session the test is looking for.
The resource that acts. Make the action a POST. The kit's own Upgrade button already works this way: it POSTs to /api/billing/checkout, the server creates the Stripe Checkout session, and the browser goes to the URL it gets back.
const res = await fetch("/api/billing/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan }),
});
A crawler can GET /pricing all day without creating a Stripe customer or session, which is the checkout route's design paying off. For your own route handlers, export POST for anything that changes state, and reach it from a form or a button, never from <Link>.
The number you watch. Count checkout sessions with an email typed, or orders. A raw session count measures how often something fetched a URL.
Things that bite
- It only happens after deploy. Prefetching is off in
next dev, so a GET route handler behind a<Link>behaves in development and fires on page views in production. prefetch={false}is not the fix. It stops Next's own prefetch, but crawlers, link checkers and previews still GET the URL. Checking theNext-Router-Prefetchheader has the same gap.- One-click links in email are the same problem. An unsubscribe or confirm link that acts on GET is acted on by whatever fetches it first. Make the GET show a page with a button that POSTs.
- Expired is not gone. 27 of the 29 sessions expired by themselves, and the API's total count still includes every one.
Shipwright, the Next.js and Claude starter kit this blog documents, keeps every billing action behind a POST route and meters the AI side per request. You can try the metered chat in the live demo.