Technical SEO

IndexNow in Next.js: key file, API route and what it does (and doesn't) do

Working App Router code for IndexNow: a key file route, a submit helper with batching and error handling, publish hooks, and how to check it in Bing Webmaster Tools. And the engine it doesn't reach: Google.

By SEOFix TeamPublished Updated
1,886 words · 9 min readMarkdown
IndexNow in Next.js: key file, API route and what it does (and doesn't) do

To add IndexNow to a Next.js App Router site, you need three pieces: a key file the search engines can fetch from your domain (a route.ts at a fixed path works well), a small server-side helper that POSTs changed URLs to https://api.indexnow.org/indexnow in batches of up to 10,000, and a call to that helper wherever content changes, such as the server action or webhook route that already calls revalidatePath. It takes about 60 lines. It notifies Bing, Yandex, Naver, Seznam, Yep and Amazon. It does not notify Google.

Below is the full code, the key file pitfalls that cause most failed setups, and how to confirm in Bing Webmaster Tools that it works.

What IndexNow reaches, and what it doesn't

IndexNow is a protocol for telling search engines that a URL was added, changed or deleted. The IndexNow FAQ lists the participating engines as Amazon, Bing, Naver, Seznam.cz, Yandex and Yep, and says a submission to any endpoint "will be shared across all IndexNow-enabled search engines" (IndexNow FAQ). So you submit once, to one endpoint.

Google isn't on that list. Google's own documentation on asking for a recrawl names two methods, the URL Inspection tool for individual URLs and sitemaps for many, and doesn't mention IndexNow (Google Search Central, ask Google to recrawl). In November 2021 Google told Search Engine Journal it would be "testing the potential benefits of this protocol" (Search Engine Journal). We found no Google documentation since then saying it uses IndexNow. For Google, keep your sitemap accurate (with real lastmod values) and your internal links strong.

That makes IndexNow worth the hour it takes, with the right expectations: faster pickup on Bing and the other participating engines, nothing on Google.

Step 1: Generate a key

The key is 8 to 128 characters and "can contain only the following characters: lowercase characters (a-z), uppercase characters (A-Z), numbers (0-9), and dashes (-)" (IndexNow documentation). A 32-character hex string is fine:

openssl rand -hex 16
# 3f9c2a71b0d4e8a63f9c2a71b0d4e8a6

Put it in your environment, not your code:

# .env.local and your hosting provider's env settings
INDEXNOW_KEY=3f9c2a71b0d4e8a63f9c2a71b0d4e8a6
SITE_URL=https://www.example.com

The key isn't a secret in the usual sense. The search engines read it from your public site to confirm the submission came from the site owner. Keeping it in an env var just means you can rotate it without a commit, and it stays the same across every environment that serves your production domain.

Step 2: Serve the key file

The protocol gives you two options. Host {key}.txt at the root of your domain, or host the file anywhere on the same host and tell the engines where with keyLocation in every submission (IndexNow documentation).

Option A: a static file. Put public/3f9c2a71b0d4e8a63f9c2a71b0d4e8a6.txt in your repo, containing only the key. It's the least code, but the key now lives in git and rotating it means a deploy.

Option B: a route handler at a fixed path. Next.js route handlers can return non-UI responses, and a folder name can contain a dot, as in the docs' own app/rss.xml/route.ts example (Next.js route.js reference). Serve the key at /indexnow-key.txt and send keyLocation with each submission:

// app/indexnow-key.txt/route.ts
export const dynamic = "force-dynamic"; // read the env var at request time, not build time

export function GET(): Response {
  const key = process.env.INDEXNOW_KEY ?? "";
  if (!/^[a-zA-Z0-9-]{8,128}$/.test(key)) {
    return new Response("Not found\n", { status: 404 });
  }
  return new Response(key, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "public, max-age=3600",
    },
  });
}

This is close to what we run on seofix.ai for our own blog. The file sits at the root of the host on purpose: the key file's location limits which URLs it can vouch for. The IndexNow docs give the example that a key at http://example.com/catalog/key12457EDd.txt "can include any URLs starting with http://example.com/catalog/ but cannot include URLs starting with http://example.com/help/" (IndexNow documentation). Keep the file at the root and it covers the whole host.

Key file pitfalls

If the key file doesn't answer 200 with the key, on the exact host you submit for, the engines can't confirm your submissions. Check it the way a search engine would:

curl -sI https://www.example.com/indexnow-key.txt
# HTTP/2 200
# content-type: text/plain; charset=utf-8

curl -s https://www.example.com/indexnow-key.txt
# 3f9c2a71b0d4e8a63f9c2a71b0d4e8a6

Things that break it:

  • Wrong host. The host in your submission, the host of every URL and the host of the key file must match. example.com and www.example.com are different hosts. Use whichever one your canonical URLs use.
  • Redirects. An apex-to-www redirect, an http to https redirect or a trailing-slash rule in front of the key file. Test the exact URL you send as keyLocation and make sure the first response is a 200.
  • Middleware or proxy. Auth checks, locale prefixes (/en/indexnow-key.txt) or bot protection that runs on every path. Exclude the key file path from the matcher.
  • A firewall challenge. If your CDN challenges unknown bots, the engines may get a challenge page instead of the key. The same rule will hurt their crawling of your pages too (see is Cloudflare blocking Googlebot? for how to check).
  • A cached 404. If you deployed the route after a crawler had already requested the file, your CDN may still be serving the old 404. Purge it.
  • Extra content. The file should contain the key and nothing else: no HTML wrapper from a catch-all page, no comments.

Step 3: A submit helper with batching and error handling

The bulk API takes a JSON POST with host, key, keyLocation and urlList, and the documentation says "You can submit up to 10,000 URLs per post" (IndexNow documentation). Bing's setup guide uses the Content-Type: application/json; charset=utf-8 header (Bing, IndexNow get started).

The response codes you'll see (IndexNow documentation, Bing get started):

CodeMeaningWhat to do
200OKDone
202Accepted: "URL received. IndexNow key validation pending."Done. The key file will be checked later, so make sure it's reachable
400Bad requestFix the payload
403Key not valid (not found, or doesn't match the file)Check the key file and keyLocation
422URLs don't belong to the host, or the key doesn't match the protocol's formatFilter URLs by host
429Too many requests (potential spam)Stop and submit less, less often

Here is a standalone helper:

// lib/indexnow.ts
const ENDPOINT = "https://api.indexnow.org/indexnow";
const MAX_URLS_PER_REQUEST = 10_000;

const SITE_URL = process.env.SITE_URL ?? "https://www.example.com"; // the exact host you publish on
const KEY = process.env.INDEXNOW_KEY ?? "";
const KEY_LOCATION = `${SITE_URL}/indexnow-key.txt`;

export type IndexNowResult = {
  submitted: number;
  skipped: string[];
  batches: { size: number; status: number | "network_error"; ok: boolean }[];
};

export async function submitToIndexNow(urls: string[]): Promise<IndexNowResult> {
  const host = new URL(SITE_URL).host;
  const result: IndexNowResult = { submitted: 0, skipped: [], batches: [] };

  if (!/^[a-zA-Z0-9-]{8,128}$/.test(KEY)) {
    throw new Error("INDEXNOW_KEY is missing or not 8-128 characters of a-z, A-Z, 0-9 and -");
  }

  // Same host only, absolute URLs only, no duplicates, no fragments.
  const unique = new Set<string>();
  for (const raw of urls) {
    try {
      const u = new URL(raw);
      u.hash = "";
      if (u.host === host && (u.protocol === "https:" || u.protocol === "http:")) unique.add(u.toString());
      else result.skipped.push(raw);
    } catch {
      result.skipped.push(raw);
    }
  }
  const list = [...unique];

  for (let i = 0; i < list.length; i += MAX_URLS_PER_REQUEST) {
    const batch = list.slice(i, i + MAX_URLS_PER_REQUEST);
    let status: number | "network_error";
    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/json; charset=utf-8" },
        body: JSON.stringify({ host, key: KEY, keyLocation: KEY_LOCATION, urlList: batch }),
        signal: AbortSignal.timeout(15_000),
      });
      status = res.status;
    } catch {
      status = "network_error";
    }

    const ok = status === 200 || status === 202;
    result.batches.push({ size: batch.length, status, ok });
    if (ok) result.submitted += batch.length;

    if (status === 403 || status === 422 || status === 429) break; // the next batch would fail the same way
  }

  return result;
}

A few decisions in there:

  • Filter before sending. One URL on the wrong host can get the request rejected with 422, so drop anything that isn't on your host instead of letting the batch fail.
  • Stop on 403, 422 and 429. These are about your key, your URLs or your rate, not one bad batch. Sending the next 10,000 URLs won't go better.
  • Don't retry blindly. A network timeout can happen after the engine already received the batch. If you add retries, keep them few and delayed.
  • Log the result. Return the batch statuses and write them to your logs. When something stops working three months from now, you'll want the history.

Step 4: Submit when content changes

Call the helper wherever you already invalidate the cache. Use after so the submission runs after the response is sent and doesn't slow down the editor's save. after is stable since Next.js 15.1 and can be used in Server Functions and Route Handlers (Next.js after reference).

From a server action (your own admin UI)

// app/admin/jobs/actions.ts
"use server";

import { after } from "next/server";
import { revalidatePath } from "next/cache";
import { submitToIndexNow } from "@/lib/indexnow";
import { saveJob } from "@/lib/jobs";

const SITE_URL = process.env.SITE_URL!;

export async function publishJob(formData: FormData) {
  const job = await saveJob(formData);

  revalidatePath(`/jobs/${job.slug}`);
  revalidatePath("/jobs");

  if (process.env.VERCEL_ENV === "production" || process.env.APP_ENV === "production") {
    after(async () => {
      const result = await submitToIndexNow([`${SITE_URL}/jobs/${job.slug}`, `${SITE_URL}/jobs`]);
      console.log("indexnow", JSON.stringify(result.batches));
    });
  }
}

Keep the environment check. Preview deployments and local dev share your env vars more often than you'd think, and you don't want a preview build announcing URLs, or a test run hitting the rate limit for your real domain. Use whichever variable your host sets for production.

From a route handler (a headless CMS webhook)

// app/api/revalidate/route.ts
import { after } from "next/server";
import { revalidatePath } from "next/cache";
import { submitToIndexNow } from "@/lib/indexnow";

const SITE_URL = process.env.SITE_URL!;

export async function POST(request: Request) {
  if (request.headers.get("x-webhook-secret") !== process.env.CMS_WEBHOOK_SECRET) {
    return new Response("Unauthorized", { status: 401 });
  }

  const { paths } = (await request.json()) as { paths: string[] }; // e.g. ["/blog/my-post", "/blog"]
  for (const path of paths) revalidatePath(path);

  after(async () => {
    const urls = paths.map((p) => `${SITE_URL}${p}`);
    await Promise.allSettled(urls.map((u) => fetch(u, { method: "GET" }))); // warm the new versions
    const result = await submitToIndexNow(urls);
    console.log("indexnow", JSON.stringify(result.batches));
  });

  return Response.json({ revalidated: paths.length });
}

Why the warm-up fetch: when called from a route handler, revalidatePath "marks the path for revalidation. The revalidation is done on the next visit to the specified path" (Next.js revalidatePath reference). Requesting the page yourself before you submit it means the page has been regenerated by the time a crawler shows up.

Deleted pages

Submit those too. The IndexNow FAQ says you should submit redirected URLs and pages that return 404 or 410, so the engines can update their index (IndexNow FAQ). On a job board, that's the moment a listing expires.

Batching on high-churn sites

Submitting one URL per save is fine for a blog. A job board or marketplace that changes thousands of rows a day should collect changes and submit them in batches.

The FAQ's guidance on frequency: "Avoid submitting the same URL many times a day unless there are meaningful content changes", wait "at least 10 minutes before resubmitting the same URL, unless it has changed significantly", and don't submit URLs that changed before you started using IndexNow (IndexNow FAQ).

A simple pattern is a scheduled route that sends everything changed since the last run:

// app/api/cron/indexnow/route.ts (called every 15 minutes by your scheduler)
import { submitToIndexNow } from "@/lib/indexnow";
import { db } from "@/lib/db";

const SITE_URL = process.env.SITE_URL!;

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const since = await db.getSetting("indexnow_last_run"); // ISO timestamp
  const now = new Date().toISOString();
  const slugs = await db.jobSlugsChangedSince(since);     // new, edited or expired

  const result = await submitToIndexNow(slugs.map((s) => `${SITE_URL}/jobs/${s}`));
  if (result.batches.every((b) => b.ok)) await db.setSetting("indexnow_last_run", now);

  return Response.json(result);
}

The cursor only moves forward when every batch succeeded, so a failed run is retried on the next schedule instead of silently skipping changes. Only "meaningful" changes should bump the changed-at timestamp: a new view counter isn't a content change.

Step 5: Check that it works in Bing Webmaster Tools

  1. Make a test submission and look at the status code. 200 or 202 means the request was accepted.
  2. Open Bing Webmaster Tools for your site. Bing's IndexNow Insights shows submitted, crawled and indexed counts, and details for the latest 1,000 submitted URLs, including "index status, first indexed date, and whether they were submitted late" (Bing Webmaster Blog, March 2024).
  3. For URLs that were received but not indexed, read the error details. The same post lists content quality, robots disallowed, crawl issues and dead links among them.
  4. In your server logs, look for Bingbot requests to the key file and to the URLs you submitted shortly after a submission.

If you keep getting 202 and Bingbot never requests the key file or the URLs, go back to the pitfalls list and test the key file from outside your network.

Common mistakes

  • Two senders. Your CMS plugin, your CDN and your own code all submitting the same URLs. Pick one sender.
  • Submitting everything daily. Resubmitting unchanged URLs goes against the FAQ guidance above and invites 429 responses.
  • Submitting non-canonical URLs. Submit the URL you want indexed: no tracking parameters, no noindex pages, nothing that canonicalises elsewhere.
  • Expecting Google to react. It won't. For Google, the sitemap and internal links are what work.

FAQ

Does Google support IndexNow? Google isn't among the participating engines listed by IndexNow (IndexNow FAQ), and Google's recrawl documentation lists only URL Inspection and sitemaps (ask Google to recrawl).

Do I need to submit to Bing and Yandex separately? No. A submission to one endpoint is shared with all participating engines (IndexNow FAQ).

Can I put the key file somewhere other than the root? Yes, with keyLocation, but the file's directory limits which URLs the key covers (IndexNow documentation). A fixed path at the root, like /indexnow-key.txt, covers everything.

How many URLs can I send? Up to 10,000 per POST (IndexNow documentation). Split larger lists into batches.

Or let your audit do the submitting

If you'd rather not maintain the cron job, SEOFix can be the sender. It looks for an IndexNow key your site already serves, checks that the key file answers 200 directly with no redirect, and after each audit submits the pages that are new or changed since the previous one (up to 10,000 URLs per site per UTC day). Pages with noindex, a canonical elsewhere, or a firewall block aren't sent. See IndexNow in SEOFix for the details and how to verify your site first. Turn on auto-submit after each audit, and keep a single sender.

ShareXLinkedIn