DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two of our pages answer DELETE with the whole page, the other 70 answer 405, and we never wrote a line about HTTP methods

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
curl -s -X DELETE https://pub-trivia.app/ | grep -o '<title>[^<]*'
# <title>PubTrivia | Run Live Pub Quiz Nights
Enter fullscreen mode Exit fullscreen mode

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: *)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)