DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our search engine key is in the repository, and publishing it is the whole verification model

PubTrivia is a Next.js app for running live pub quiz nights. Its marketing site has 72 URLs in the sitemap, and a sitemap is a standing statement of what exists. IndexNow is the other kind of message: an event saying that one specific URL is new or different, which the participating engines act on in minutes rather than whenever they next feel like crawling.

Wiring it up took about a hundred lines. Almost all of the interesting decisions came from one property of the protocol.

You can read our key right now

curl https://pub-trivia.app/2382c7da66fd467eb45e9c97aadf605a.txt
Enter fullscreen mode Exit fullscreen mode

That returns 2382c7da66fd467eb45e9c97aadf605a, which is the key, and the filename is the key too. Open it in a browser if you prefer: the key file.

Here is why that is fine. A submission is a JSON POST carrying four fields: the host, the key, a keyLocation URL, and the list of changed URLs. The receiving engine fetches keyLocation and checks that the body is the key you claimed. The proof is not that you know a secret, it is that you can make a file appear at a URL on the host you are speaking for. Anyone being able to read it is the entire mechanism.

So our key is a constant in a source file that is checked into the repository. Hiding it in an environment variable would protect nothing, and it would separate the key from the one thing it has to agree with: the route that serves it.

The key file is a route, not a file in public/

The obvious move is to drop a .txt into public/ and move on. On this site that fails, and it fails quietly.

PubTrivia has an auth gate in middleware. The matcher excludes static chunks, the image optimiser, the favicon, the web manifest, robots.txt, sitemap.xml and anything ending in an image extension. A .txt in public/ matches nothing on that list, so it reaches the gate, and the gate answers an unauthenticated request with a redirect to /login. IndexNow would fetch the key file, receive a 307 pointed at a sign-in form, and report a bare 403.

The file is therefore a route segment whose directory name is literally the key:

// app/2382c7da66fd467eb45e9c97aadf605a.txt/route.ts
export const dynamic = 'force-static'

export function GET() {
    return new Response(INDEXNOW_KEY, {
        headers: {
            'Content-Type': 'text/plain; charset=utf-8',
            'Cache-Control': 'public, max-age=86400',
        },
    })
}
Enter fullscreen mode Exit fullscreen mode

and the path is also listed in the public-route allowlist that the gate reads, which is what actually lets the fetch through.

That directory name is the one place the key cannot be derived from the constant, because the name is what decides the URL. So it gets a test: the suite asserts that a directory named after the current value of INDEXNOW_KEY exists, that it has a route.ts, that calling the handler returns the key as text/plain with nothing else in the body, and that the path passes the public-route predicate. Any one of those three drifting produces the same unhelpful symptom, which is a 403 with no indication of which half is wrong.

Verify the key file before every submission, not after

Because the failure mode is that bare 403, the submit script fetches our own key file first and says what it found:

const response = await fetch(url, { redirect: 'manual' })
Enter fullscreen mode Exit fullscreen mode

redirect: 'manual' is the whole point of that line. With redirects followed, a key file that has been swallowed by the auth gate comes back as a perfectly healthy 200 containing the HTML of a login page, and the check passes while the submission keeps failing. Left unfollowed, the 307 is visible, and the script can say so in the one sentence that matters: the key file must return 200 with the key as its body.

It also keeps the documented status codes in a lookup table rather than printing the number on its own, because 422 and 403 mean two genuinely different things here and neither says so:

export const INDEXNOW_STATUS_MEANING: Record<number, string> = {
    200: 'accepted',
    202: 'accepted, key validation pending',
    400: 'bad request, the payload is malformed',
    403: 'forbidden, the key file could not be fetched or did not match',
    422: 'unprocessable, a URL is not on this host, or the key does not match',
    429: 'rate limited, too many submissions',
}
Enter fullscreen mode Exit fullscreen mode

One off-origin URL fails all of them

Every URL in a submission must be on the host named in the payload. One stray absolute URL from another origin does not get skipped, it fails the whole batch with a 422. The payload builder therefore rejects anything that is not root-relative before the request goes out, and converts the paths itself, so an off-origin URL is a local error with a path in the message rather than a status code you have to go and look up.

The same builder enforces the spec's ceiling of 10,000 URLs per request. Our sitemap is at 72, so that limit will never fire, but a limit that cannot fire is still cheaper to encode once than to rediscover.

The URL list is not a second list

The script's default is to submit everything in the sitemap, which is what a first submission wants. It gets that list from the same exported array the sitemap route is built from, not from a list written out again in the script.

You can check the join from outside:

curl -s https://pub-trivia.app/sitemap.xml | grep -c '<loc>'
Enter fullscreen mode Exit fullscreen mode

72, which is the number of URLs a full submission sends. There is no arrangement where the sitemap and the submission disagree about what this site publishes, because there is no second list to disagree with.

Naming paths explicitly is the normal case after the first run:

pnpm indexnow /guides/how-to-host-a-pub-quiz
pnpm indexnow --dry-run
Enter fullscreen mode Exit fullscreen mode

and a path that is not in the sitemap is a hard error rather than a submission, because it is either a typo or a page that should be in the sitemap first.

Two guards sit around that command for reasons that are not obvious until they bite. The script refuses to run unless the resolved site URL is the production one, because the base URL falls back to localhost outside production, and a submission full of localhost URLs is rejected with a 422 that reads exactly like a key problem. And resubmitting all 72 unchanged pages on every deploy is worse than not submitting at all: the signal means "this changed", and an engine that keeps being told seventy lies learns to discount the source.

What it does not buy you

Bing, Yandex, Seznam and Naver participate. Google does not. IndexNow is additive to the sitemap you have already given Search Console, and anyone describing it as a way to get indexed by Google faster is selling something.

Submitting to the shared endpoint at api.indexnow.org notifies every participating engine at once, which is why there is no reason to post to an individual engine's own URL.


The live surfaces are all public if you want to poke at them: the key file, the sitemap it is built from, and robots.txt, which points at that same sitemap. The app those 72 pages are selling is at pub-trivia.app, and the free tier does not ask for a card if you want to see what the pages are describing.

Top comments (0)