Nothing in this repository mentions an HTTP method except five route handlers under /api, each of which exports exactly one function called POST and nothing else. So I went and asked the live site what it does with the rest, and got four different answers depending on which kind of URL I asked.
A prerendered page
/features is static HTML on Vercel's edge:
GET 200 81,952 bytes
HEAD 200 0
OPTIONS 204 0
POST 405 0
PUT 405 0
PATCH 405 0
DELETE 405 0
That is the CDN answering, and it is a reasonable policy for a document. Two things stand out in the headers.
The OPTIONS response is the interesting one:
HTTP/2 204
access-control-allow-methods: OPTIONS, GET, HEAD
access-control-allow-origin: *
age: 96033
It will tell you which methods it accepts, under a CORS header, because that is the header a browser preflight asks for. And the POST response, five requests later:
HTTP/2 405
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
No Allow header. RFC 9110 is not ambiguous about this: a 405 response "MUST generate an Allow header field containing a list of the target resource's currently supported methods". The information plainly exists, since the sibling 204 just published it. It is simply not in the response that is defined by needing it.
Also note x-vercel-cache: HIT. The 405 is cached.
A page that renders per request
/ and /pricing are the only two of our 72 URLs that are not prerendered. They are force-dynamic, because they price in the visitor's own currency and a currency read from a request header cannot be baked at build time.
Same probe:
OPTIONS / 200 150,473 bytes
POST / 200 150,475 bytes
DELETE / 200 150,508 bytes
curl -s -X DELETE https://pub-trivia.app/ | grep -o '<title>[^<]*'
# <title>PubTrivia | Run Live Pub Quiz Nights
DELETE returns the homepage. So does OPTIONS, with 150 KB of HTML in a response that is supposed to describe communication options, and no access-control-allow-methods in sight.
This is not a vulnerability and it is not a bug report. A React Server Component render has nothing to delete, and the function is a request-in, HTML-out pipeline that was never given a reason to look at the verb. It is a difference in behaviour between two pages of the same site, and its cause is a feature three layers away: we show prices in local currency, so two pages became functions, so two pages stopped having the CDN's opinion about methods.
The thing I take from it is that "this page is dynamic" is never only a caching decision. It moves the page to a different machine with different defaults for everything nobody thought about.
Metadata routes and route handlers
app/sitemap.ts and app/robots.ts are not pages. They answer:
POST /sitemap.xml 405
POST /robots.txt 405
HEAD /sitemap.xml 200 (and access-control-allow-origin: *)
Those five route handlers are the only place in the codebase where anybody made a method decision, and they made it by exporting one function:
GET /api/webhook/stripe 405 (Next's own answer: no GET is exported)
POST /api/webhook/stripe 400 (ours: no stripe-signature header)
That 400 is the first response in this whole post that our code produced. Five URLs have a method policy of their own, and between them they handle payments, mail and the server-to-server calls, which is at least the right order of priority.
While I had curl open: the image optimizer
Four requests, four refusals, none of them configured by us:
# a foreign host
/_next/image?url=https%3A%2F%2Fexample.com%2Fa.png&w=640&q=75 400
# a width that is not in the allowed list
/_next/image?url=%2Fdemo-1.webp&w=999&q=75 400
# an SVG
/_next/image?url=%2Flogo.svg&w=640&q=75 400
# a quality that is not in the allowed list
/_next/image?url=%2Fdemo-1.webp&w=640&q=100 400
All four are Next.js defaults: remotePatterns is unset so no other origin may be proxied, widths and qualities are allowlisted so the optimizer cannot be made to render arbitrary variants, and SVG is off because an SVG is a script container. We did not write a single one of those rules, and three of them are the kind of thing people only discover after somebody uses their image endpoint as a free CDN.
The same endpoint also negotiates, which is easy to forget when you test with curl:
curl -I "https://pub-trivia.app/_next/image?url=%2Fdemo-1.webp&w=640&q=75"
# content-type: image/jpeg 7,067 bytes
curl -I -H "Accept: image/webp" ".../_next/image?url=%2Fdemo-1.webp&w=640&q=75"
# content-type: image/webp 4,254 bytes
Ask with Accept: image/avif and you still get JPEG, because the default formats list is WebP only. The original file on disk is 1,769,688 bytes, so the version a browser actually receives at that size is 1/416th of it.
And the encodings
Accept-Encoding: br 13,457 bytes content-encoding: br
Accept-Encoding: gzip 16,077 bytes content-encoding: gzip
Accept-Encoding: zstd 81,952 bytes (none)
Accept-Encoding: deflate 81,952 bytes (none)
Brotli and gzip, nothing else. zstd has been in Chrome since version 123 and would shave a little more off, and it is not a thing a Next.js app can decide: the edge compresses, and we get whatever it supports.
The point
Every response in this post except one 400 is a default somebody else chose. The split between them is not even stable across our own site, because a page can change category, quietly, as a side effect of a feature about money.
If you are shipping a Next.js app, this is a ten minute exercise worth doing on your own domain. Write down which of your pages are prerendered, then probe both kinds. You will learn which of your URLs are documents on a CDN and which are functions, and you will learn it from the outside, which is where your users and the crawlers are standing.
Check it yourself
for m in GET HEAD OPTIONS POST PUT DELETE; do
printf '%-8s' "$m"
curl -s -o /dev/null -X "$m" -w '%{http_code} %{size_download} bytes\n' \
https://pub-trivia.app/features
done
# Now the same loop against the homepage, which is a function.
The prerendered list is in the sitemap; the two exceptions are the homepage and the pricing page, and you can tell which is which by the presence of x-nextjs-prerender: 1 in the response headers.
Top comments (0)