DEV Community

Cover image for IndexNow in Next.js: Instant Indexing After Every Deploy
Iurii Rogulia
Iurii Rogulia

Posted on • Originally published at iurii.rogulia.fi

IndexNow in Next.js: Instant Indexing After Every Deploy

You shipped a new blog post on Monday. By Friday it still wasn't in Google's index. You manually submitted it via Search Console. Saturday it appeared. You wonder whether IndexNow would have made any difference.

It would. But not in the way most tutorials describe.

IndexNow is a push protocol — instead of waiting for a crawler to discover your URL, you notify search engines the moment it changes. For Bing and Yandex, this typically reduces discovery latency from days to hours. Not guaranteed, not always dramatic — but measurable, and free to implement.

Here's what IndexNow actually does, how to implement it properly, and where it breaks.

What IndexNow Does and Does Not Do

IndexNow notifies participating search engines that a URL has changed and they should crawl it. It does not guarantee indexing. It does not bypass quality signals. A thin page that wouldn't rank will still not rank — IndexNow just makes the crawler aware of it faster.

The protocol was launched by Microsoft and Yandex in 2021. As of 2026, participating engines include:

Search Engine Endpoint Notes
Bing https://www.bing.com/indexnow Primary adopter, best documented
Yandex https://yandex.com/indexnow Active participant
Seznam.cz https://search.seznam.cz/indexnow Czech market
Naver https://searchadvisor.naver.com/indexnow South Korean market
Yep https://indexnow.yep.com/indexnow Smaller engine

Google is notably absent. Google has its own Indexing API, which is limited to JobPosting and BroadcastEvent structured data, and a separate experimental URL submission endpoint in Search Console that is not publicly documented as a production API.

The sharing mechanism: When you submit a URL to any one participating engine, that engine forwards the notification to all others within roughly 10 seconds. In practice, you only need to submit to one endpoint. I submit to Bing — it reaches Yandex and the rest automatically.

When IndexNow Actually Matters

Not every site benefits equally. A decision framework:

High impact:

  • Frequently updated content (news, product listings, inventory pages)
  • Pages with weak internal linking — crawlers may not discover them organically for days
  • Newly launched sites with low crawl frequency (Googlebot visits infrequently until you build authority; Bing is more responsive to direct signals)
  • Sites with high URL churn (e-commerce with seasonal catalogs, SaaS changelogs)

Low impact:

  • Static marketing pages updated rarely
  • Strongly interlinked content already crawled at high frequency
  • Google-first SEO strategies — IndexNow has zero effect on Google

Not a substitute for:

  • An XML sitemap (still required for baseline discovery)
  • Internal linking (crawl path architecture)
  • RSS/Atom feeds (alternative discovery for content aggregators)

Rule of thumb: if your content has a half-life measured in hours or days, IndexNow pays off. If your content is evergreen and well-linked, it's nice to have but not urgent.

name="How to implement IndexNow in a Next.js site"
totalTime="PT2H"
tools={["TypeScript", "Next.js", "GitHub Actions", "openssl"]}
steps={[
{
name: "Generate and store the key",
text: "Run openssl rand -hex 32 to create a 64-character hex key. Store it as an environment variable INDEXNOW_KEY. You'll need it in both the key file on your server and every API request.",
},
{
name: "Host the key file",
text: "Place a plain text file at public/{your-key}.txt containing exactly the key string. In Next.js the public directory serves statically at the root, making it accessible at https://yourdomain.com/{key}.txt.",
},
{
name: "Build a TypeScript client with retry logic",
text: "Create a submitUrls function that uses GET for single URLs and POST for batches. Implement fetchWithRetry with exponential backoff for 429 responses and respect the Retry-After header. Return structured results for logging.",
},
{
name: "Wire submission into the deploy pipeline",
text: "Trigger IndexNow after deployment is live — not during build. Use git diff to extract only changed URLs rather than submitting the full sitemap. Always include the homepage and section index when content changes.",
},
{
name: "Submit deleted and redirected URLs",
text: "Notify IndexNow when pages return 404, 410, or 301 — this accelerates removal and redirect propagation from the search index rather than waiting for the next crawl cycle.",
},
{
name: "Add dedup to prevent spam signals",
text: "Track last submission timestamp per URL and skip resubmission within a 24-hour cooldown window. Flooding the same URL repeatedly causes engines to deprioritize your submissions as noise.",
},
]}
/>

Generating a Key

IndexNow authentication uses a key file on your domain. The key proves you own the domain you are submitting URLs for.

Key format requirements: 8 to 128 characters, lowercase letters, uppercase letters, numbers, and dashes only.

Generate one yourself — no third-party service needed:

# Option 1: openssl (most reliable, available everywhere)
openssl rand -hex 32
# → a3f8e2b1c4d9...  (64 hex characters)

# Option 2: UUID v4 without dashes
node -e "console.log(
  require('crypto')
    .randomUUID()
    .replace(/-/g, '')
)"
# → 550e8400e29b41d4a716446655440000

# Option 3: Python if you prefer
python3 -c "
import secrets
print(secrets.token_hex(32))
"
Enter fullscreen mode Exit fullscreen mode

The openssl output is already valid. UUID v4 with dashes removed is also valid — dashes are allowed but UUIDs without them are shorter and equally unguessable.

Store this key somewhere permanent. You'll need it in two places: the key file on your server, and the API request parameter. Put it in an environment variable:

INDEXNOW_KEY=a3f8e2b1c4d9...
Enter fullscreen mode Exit fullscreen mode

Hosting the Key File

Place a UTF-8 plain text file at:

https://yourdomain.com/{your-key}.txt
Enter fullscreen mode Exit fullscreen mode

The file must contain exactly the key string, optionally followed by a newline. Nothing else.

# In your public directory
echo "a3f8e2b1c4d9..." \
  > public/a3f8e2b1c4d9....txt
Enter fullscreen mode Exit fullscreen mode

For Next.js, the public/ directory is served statically at the root. A file at public/a3f8e2b1c4d9....txt becomes https://yourdomain.com/a3f8e2b1c4d9....txt. That is all IndexNow needs to verify ownership.

One alternative: the keyLocation parameter lets you host the key file at a non-root path. But there is a catch — a key file at /catalog/key.txt can only authorize submissions for URLs starting with /catalog/. If you need to submit URLs from across your site, root placement is the right choice.

The API: Single URL and Batch

Single URL submission

GET https://www.bing.com/indexnow?url=https://yourdomain.com/blog/new-post&key=a3f8e2b1c4d9...
Enter fullscreen mode Exit fullscreen mode

Returns 200 or 202. That's it.

Batch submission (up to 10,000 URLs)

POST https://www.bing.com/indexnow
Content-Type: application/json; charset=utf-8

{
  "host": "yourdomain.com",
  "key": "a3f8e2b1c4d9...",
  "urlList": [
    "https://yourdomain.com/blog/post-one",
    "https://yourdomain.com/blog/post-two",
    "https://yourdomain.com/products/new-product"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The keyLocation field is optional — omit it if your key file is at the root.

HTTP response codes:

Code Meaning
200 Submitted and processed
202 Received, pending key validation
400 Invalid format — check your JSON structure
403 Key rejected — verify the key file is accessible
422 URLs don't match the host field
429 Rate limit — back off and retry

TypeScript Implementation

A production-ready IndexNow client with retry logic and structured logging:

// lib/indexnow.ts

const INDEXNOW_ENDPOINT = "https://www.bing.com/indexnow";

interface IndexNowConfig {
  host: string;
  key: string;
  keyLocation?: string;
}

interface SubmitResult {
  ok: boolean;
  status: number;
  retryAfter?: number;
  urlCount: number;
  durationMs: number;
}

async function fetchWithRetry(
  input: RequestInfo,
  init: RequestInit,
  retries = 3
): Promise<Response> {
  const res = await fetch(input, init);

  if (res.status !== 429 || retries === 0) return res;

  const retryAfterHeader = res.headers.get("Retry-After");
  // Exponential backoff as fallback when Retry-After is absent
  const delay = retryAfterHeader
    ? parseInt(retryAfterHeader, 10) * 1000
    : 2 ** (3 - retries) * 1000;

  await new Promise((r) => setTimeout(r, delay));

  return fetchWithRetry(input, init, retries - 1);
}

export async function submitUrls(urls: string[], config: IndexNowConfig): Promise<SubmitResult> {
  const start = Date.now();
  if (urls.length === 0) {
    return { ok: true, status: 200, urlCount: 0, durationMs: 0 };
  }

  // Single URL: GET is simpler; batch: POST is required
  if (urls.length === 1) {
    const params = new URLSearchParams({ url: urls[0], key: config.key });
    const response = await fetchWithRetry(`${INDEXNOW_ENDPOINT}?${params}`, { method: "GET" });

    const result: SubmitResult = {
      ok: response.ok,
      status: response.status,
      urlCount: 1,
      durationMs: Date.now() - start,
    };

    if (response.status === 429) {
      const retryAfter = response.headers.get("Retry-After");
      result.retryAfter = retryAfter ? parseInt(retryAfter, 10) : 60;
    }

    return result;
  }

  // Batch: chunk into groups of 10,000 (API limit)
  const chunks = chunkArray(urls, 10_000);

  for (const chunk of chunks) {
    const body = {
      host: config.host,
      key: config.key,
      ...(config.keyLocation && { keyLocation: config.keyLocation }),
      urlList: chunk,
    };

    const response = await fetchWithRetry(INDEXNOW_ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json; charset=utf-8" },
      body: JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = response.headers.get("Retry-After");
      return {
        ok: false,
        status: 429,
        retryAfter: retryAfter ? parseInt(retryAfter, 10) : 60,
        urlCount: urls.length,
        durationMs: Date.now() - start,
      };
    }

    // Return immediately on hard errors — 400/403/422 won't resolve by retrying
    if (response.status >= 400) {
      return {
        ok: false,
        status: response.status,
        urlCount: urls.length,
        durationMs: Date.now() - start,
      };
    }
  }

  return { ok: true, status: 200, urlCount: urls.length, durationMs: Date.now() - start };
}

function chunkArray<T>(arr: T[], size: number): T[][] {
  const chunks: T[][] = [];
  for (let i = 0; i < arr.length; i += size) {
    chunks.push(arr.slice(i, i + size));
  }
  return chunks;
}
Enter fullscreen mode Exit fullscreen mode

Observability

submitUrls returns structured data — log it, don't swallow it.

const result = await submitUrls(changedUrls, {
  host: process.env.SITE_HOST!,
  key: process.env.INDEXNOW_KEY!,
});

// Structured log — send to Datadog, ELK, or your logging system
console.log(
  JSON.stringify({
    event: "indexnow.submit",
    ok: result.ok,
    status: result.status,
    urlCount: result.urlCount,
    durationMs: result.durationMs,
    retryAfter: result.retryAfter ?? null,
    timestamp: new Date().toISOString(),
  })
);

// Alert on repeated 429 or 403 — these indicate configuration problems
if (result.status === 403) {
  // Key file not accessible — deployment issue
  throw new Error(
    `IndexNow key validation failed (403). Check key file at /${process.env.INDEXNOW_KEY}.txt`
  );
}
Enter fullscreen mode Exit fullscreen mode

At minimum, log status, urlCount, and durationMs per submission. A sudden spike in 429s means you're submitting too frequently. A persistent 403 means your key file vanished — this happens after CDN config changes or public directory restructuring.

slug="mvp-development"
text="Every Next.js project I ship includes IndexNow, structured sitemaps, and JSON-LD — the full technical SEO stack built in from day one, not retrofitted later."
/>

Submitting on Build: The Right Approach

The most reliable trigger for IndexNow submission is your build pipeline — immediately after new content is deployed. Here are three patterns.

Pattern 1: Post-deploy shell script

#!/usr/bin/env bash
# scripts/submit-indexnow.sh
set -euo pipefail

HOST="${SITE_HOST:-yourdomain.com}"
KEY="${INDEXNOW_KEY}"
ENDPOINT="https://www.bing.com/indexnow"

if [[ -z "$KEY" ]]; then
  echo "INDEXNOW_KEY is not set" >&2
  exit 1
fi

# Fetch the sitemap and extract URLs via XML parser (limit to 1000)
URLS=$(curl -s "https://${HOST}/sitemap.xml" \
  | python3 -c "
import sys
from xml.etree import ElementTree as ET
root = ET.fromstring(sys.stdin.read())
locs = [el.text.strip() for el in root.iter('{http://www.sitemaps.org/schemas/sitemap/0.9}loc') if el.text]
print('\n'.join(locs[:1000]))
")

# Guard: nothing to submit
if [[ -z "$URLS" ]]; then
  echo "No URLs found in sitemap"
  exit 0
fi

JSON_URLS=$(echo "$URLS" | python3 -c "
import sys, json
urls = [line.strip() for line in sys.stdin if line.strip()]
print(json.dumps(urls))
")

PAYLOAD=$(python3 -c "
import json, sys
data = {
  'host': '${HOST}',
  'key': '${KEY}',
  'urlList': json.loads(sys.argv[1])
}
print(json.dumps(data))
" "$JSON_URLS")

HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
  -X POST "${ENDPOINT}" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d "$PAYLOAD")

echo "IndexNow submission status: $HTTP_STATUS"

if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "202" ]]; then
  echo "Submission failed with status $HTTP_STATUS" >&2
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Pattern 2: GitHub Actions workflow

# .github/workflows/indexnow.yml
name: Submit to IndexNow

on:
  push:
    branches: [main]
    paths:
      - "content/**"  # Only trigger when content changes

jobs:
  indexnow:
    runs-on: ubuntu-latest
    needs: deploy  # Reference your deploy job here
    if: needs.deploy.result == 'success'
    env:
      SITE_HOST: ${{ vars.SITE_HOST }}  # Set in repo Settings → Variables

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Need parent commit for git diff

      - name: Extract changed URLs
        id: changed-urls
        run: |
          CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} \
            | grep '^content/' \
            | sed "s|content/blog/\(.*\)/index\.mdx|https://${SITE_HOST}/blog/\1|" \
            | sed "s|content/projects/\(.*\)/index\.mdx|https://${SITE_HOST}/projects/\1|" \
            | grep '^https://' \
            | head -1000)

          # Guard: nothing changed in content
          if [[ -z "$CHANGED" ]]; then
            echo "No content URLs changed, skipping IndexNow submission"
            echo "urls=[]" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # Always include homepage and section index on content change
          URLS=$(printf '%s\n' "$CHANGED" \
            "https://${SITE_HOST}" \
            "https://${SITE_HOST}/blog" \
            | sort -u)

          JSON=$(echo "$URLS" | python3 -c \
            "import sys,json; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))")

          echo "urls=$JSON" >> "$GITHUB_OUTPUT"

      - name: Submit to IndexNow
        if: steps.changed-urls.outputs.urls != '[]'
        env:
          INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
          # Pass JSON via env var to avoid shell injection from URL content
          URL_LIST: ${{ steps.changed-urls.outputs.urls }}
        run: |
          echo "$URL_LIST" > /tmp/urls.json
          python3 - <<'EOF'
import json, os, sys, urllib.request, urllib.error

with open('/tmp/urls.json') as f:
    urls = json.load(f)

payload = json.dumps({
    'host': os.environ['SITE_HOST'],
    'key': os.environ['INDEXNOW_KEY'],
    'urlList': urls,
}).encode('utf-8')

req = urllib.request.Request(
    'https://www.bing.com/indexnow',
    data=payload,
    headers={'Content-Type': 'application/json; charset=utf-8'},
    method='POST',
)

try:
    with urllib.request.urlopen(req) as res:
        print(f'Status: {res.status}')
        if res.status not in (200, 202):
            sys.exit(1)
except urllib.error.HTTPError as e:
    print(f'Status: {e.code}', file=sys.stderr)
    sys.exit(1)
EOF
Enter fullscreen mode Exit fullscreen mode

The needs: deploy dependency is critical. Submit only after the deployment is live — the crawler may visit within seconds of receiving the notification.

The git diff approach submits only what changed. The head -1000 cap prevents accidental bulk submission on force-pushes or history rewrites.

Pattern 3: Next.js Server Action on publish

If you have a CMS with a publish action, trigger IndexNow there:

// app/actions/publish.ts
"use server";

import { submitUrls } from "@/lib/indexnow";
import { revalidatePath } from "next/cache";

export async function publishPost(slug: string): Promise<void> {
  const siteUrl = process.env.SITE_URL;
  if (!siteUrl) throw new Error("SITE_URL env var is not set");

  let host: string;
  try {
    host = new URL(siteUrl).hostname;
  } catch {
    throw new Error(`SITE_URL is not a valid URL: ${siteUrl}`);
  }

  await markPostAsPublished(slug);

  revalidatePath(`/blog/${slug}`);
  revalidatePath("/blog");

  const result = await submitUrls([`${siteUrl}/blog/${slug}`, `${siteUrl}/blog`, siteUrl], {
    host,
    key: process.env.INDEXNOW_KEY!,
  });

  console.log(JSON.stringify({ event: "indexnow.publish", slug, ...result }));
}
Enter fullscreen mode Exit fullscreen mode

Deleted and Redirected URLs

This is the part that surprises most people: you should also submit URLs that return 404, 410, or 3xx redirects.

When you delete a page, the crawler still has the old URL in its queue. Without notification, it might take weeks before the crawler discovers the page is gone and removes it from the index. With IndexNow, you accelerate that process.

// When you delete a page
await submitUrls(
  ["https://yourdomain.com/blog/old-post"], // Will return 404 after deletion
  config
);

// When you redirect a page
await submitUrls(
  [
    "https://yourdomain.com/blog/old-slug", // Returns 301 to new URL
    "https://yourdomain.com/blog/new-slug", // The destination
  ],
  config
);
Enter fullscreen mode Exit fullscreen mode

The search engine will crawl both, follow the redirect, and update its index accordingly.

Duplicate Submissions and Spam Signals

Submitting the same URL repeatedly does not improve crawl speed and may trigger throttling.

IndexNow is a signal, not a queue. Flooding it with the same URLs reduces its effectiveness — engines start treating your submissions as noise and deprioritize them.

Best practices:

  • Track last submission timestamp per URL (Redis, a simple JSON file, or a database column)
  • Avoid resubmitting unchanged URLs within 24 hours
  • Batch updates rather than sending individual requests in loops
  • Never submit the same URL more than once per deploy unless the content actually changed
// Minimal dedup with a timestamp store
async function submitIfChanged(
  urls: string[],
  config: IndexNowConfig,
  lastSubmittedAt: Record<string, number>
): Promise<void> {
  const now = Date.now();
  const cooldown = 24 * 60 * 60 * 1000; // 24 hours in ms

  const due = urls.filter((url) => !lastSubmittedAt[url] || now - lastSubmittedAt[url] > cooldown);

  if (due.length === 0) return;

  await submitUrls(due, config);

  for (const url of due) {
    lastSubmittedAt[url] = now;
  }
}
Enter fullscreen mode Exit fullscreen mode

How IndexNow Interacts with Crawl Budget

IndexNow does not increase your crawl budget — it reprioritizes it.

Search engines allocate a limited number of crawl requests per domain per day. IndexNow moves your submitted URLs to the front of that queue. It doesn't expand the queue itself. On a large site with thousands of URLs, submitting everything on every deploy can backfire — you crowd out other pages that also need crawling.

What this means in practice:

  • Submit high-value URLs (new content, updated content, deleted content) — not the entire sitemap
  • Paginated URLs, filtered views, and parameter variants should generally not be submitted
  • Internal search result pages, session-specific URLs, and anything in your robots.txt Disallow should never be submitted

Use IndexNow selectively for high-value URLs, as part of a broader indexing strategy — not a replacement for it.

Pitfalls

1. Submitting before deployment is live

This is the most common mistake. You trigger IndexNow as part of your CI pipeline, but the deployment hasn't propagated yet. The crawler visits, finds an old version or a 404, and your URL gets deprioritized. Always add a readiness check before submitting.

for i in {1..10}; do
  URL="https://yourdomain.com/blog/new-post"
  HTTP=$(curl -s -o /dev/null \
    -w "%{http_code}" "$URL")
  if [[ "$HTTP" == "200" ]]; then break; fi
  echo "Waiting for deployment... ($i)"
  sleep 15
done
Enter fullscreen mode Exit fullscreen mode

2. Submitting the entire sitemap on every deploy

Your sitemap has 500 URLs. You submit all 500 on every content change, even when only one URL changed. This burns your daily quota and looks like spam. Submit only what changed — the git diff approach above handles this.

3. Ignoring the 429 response and Retry-After header

Each engine has undisclosed daily submission limits. The fetchWithRetry implementation above handles this with exponential backoff. Don't roll your own retry without respecting the Retry-After header.

4. Submitting subdomain content from a root domain key

IndexNow keys are domain-scoped. A key hosted at example.com cannot authorize submissions for shop.example.com. You need separate keys and separate key files for each subdomain.

5. The key file returning wrong content type or encoding

The key file must be served as plain text (text/plain), UTF-8. Some static hosting configurations will serve .txt files with charset=iso-8859-1 or add byte-order marks. Verify:

URL="https://yourdomain.com/your-key.txt"

curl -I "$URL"
# Look for: Content-Type: text/plain; charset=utf-8

curl -s "$URL" | xxd | head -1
# Should not start with EF BB BF (UTF-8 BOM)
Enter fullscreen mode Exit fullscreen mode

6. URLs with fragments or query strings

IndexNow ignores fragments (#section) — strip them before submitting. Query strings (?page=2) are submitted as distinct URLs. Be deliberate: most paginated or filtered URLs should not be in your submissions.

Fallback Strategy

IndexNow is not a replacement for your baseline indexing infrastructure. If IndexNow fails (429, 403, network error), your content should still be discoverable.

The correct indexing stack, in order of priority:

  1. XML sitemap — baseline discovery; Googlebot, Bingbot, and others check it on a schedule
  2. Internal linking — crawl path architecture; pages with no inbound links may not be crawled regardless of IndexNow
  3. RSS/Atom feed — alternative discovery for content aggregators and some search engines
  4. IndexNow — real-time push for participating engines; accelerates what the above already enables

If your sitemap is broken and your internal linking is weak, IndexNow won't save you. Fix the foundation first.

Checklist Before You Ship

□ Key generated (32+ hex chars, saved as env variable)
□ Key file at https://yourdomain.com/{key}.txt — plain text, UTF-8, no BOM
□ Key file accessible (curl returns 200 with correct content-type)
□ Submission triggers after deployment is live, not during build
□ Only changed URLs submitted, not the full sitemap every time
□ Deleted and redirected URLs included in submissions
□ fetchWithRetry with exponential backoff implemented
□ Structured logging in place (status, urlCount, durationMs)
□ Subdomain URLs handled with separate keys if applicable
□ Dedup logic: no resubmission of unchanged URLs within 24h
□ Sitemap + internal links in place as fallback
Enter fullscreen mode Exit fullscreen mode

IndexNow is a small integration with a disproportionate return on effort. Set it up once, wire it into your deploy pipeline, and your content stops sitting in a crawl queue while its relevance window closes.

Treat it as one layer in your indexing pipeline — not a shortcut around it. The sites where it makes the biggest difference are the ones where the foundation (sitemaps, internal links, clean URL structure) is already solid.

If you're building a Next.js site, SaaS, or e-commerce platform for the EU market and want the full technical SEO stack handled properly — I cover this in projects like pikkuna.fi and pi-pi.ee. Get in touch if you need a senior developer who owns the outcome end-to-end.

For MVP development that ships with this infrastructure built in — that's exactly what I do.

slug="seo-audit"
text="If your IndexNow setup is one of several technical SEO questions on your list — fixed-fee Technical SEO Audit covers indexing, schema, sitemap, hreflang, Core Web Vitals, broken links, and per-market keyword research. Written report, 5 working days."
/>


Further reading:

Top comments (0)