How to Push New URLs to Bing and Yandex the Moment You Publish

Teal cover. Headline — IndexNow on Cloudflare Workers. Right side — a flow diagram showing a CMS publish webhook triggering a Cloudflare Worker that notifies Bing, Yandex, Seznam, and Naver. Subheadline — Push new URLs the moment you publish.

IndexNow setup with a Cloudflare Worker. No terminal required.

The problem

You publish a new page or blog post. You check Bing. Nothing. You wait a day, a week. Still nothing. Search engines discover new content by crawling your site on their own schedule, and that schedule can take days or weeks.

IndexNow is a protocol that lets you notify search engines the moment something changes. Instead of waiting for a crawler, you push the update directly. Bing, Yandex, Naver, Seznam, Amazon, and Yep all support it. Google is not part of the IndexNow protocol and uses a separate Indexing API.

How this works

Cloudflare has a native IndexNow integration called Crawler Hints. You turn it on in the dashboard and Cloudflare automatically notifies search engines when cached content changes. No code needed. If your site is proxied through Cloudflare (orange cloud on your DNS records), Crawler Hints may be all you need.

Most Framer sites are not proxied. Framer requires root A records to be DNS-only for domain verification, so Cloudflare never sees the traffic and Crawler Hints has nothing to detect. Framer does support reverse proxying through Cloudflare, but only on Scale and Enterprise plans. If you are on a standard Framer plan, Crawler Hints will not work for your site.

The Worker approach in this guide works regardless of your DNS setup or plan. It reads the sitemap directly from your site, compares it against a saved snapshot from the previous run, and submits only new or removed URLs to IndexNow.

The snapshot is stored in Cloudflare KV, a key-value storage service. You set it up once. After that, it runs daily on a cron schedule with no manual steps.

If you are on Framer, there are two extra DNS considerations. These are called out in the relevant steps.

What you need

  • Any website with a sitemap at yoursite.com/sitemap.xml

  • A Cloudflare account (free plan is sufficient)

  • Your domain's DNS managed by Cloudflare

If your DNS is not yet on Cloudflare, add your domain and update the nameservers at your registrar first. Worker Routes only work on domains managed through Cloudflare.

Step 1: Generate your IndexNow key

Generate a key — any alphanumeric string, 8 to 128 characters long, hyphens allowed. A UUID works. Quickest path: open any browser console and run crypto.randomUUID().replace(/-/g, ''). Save the key — you will need it several times in this guide. For the format spec see the IndexNow FAQ.

Your key will look like this: YOUR_INDEXNOW_KEY

Step 2: Create a KV Namespace

KV (Key-Value) is Cloudflare's storage service. This is where the Worker saves its snapshot of your sitemap URLs between runs. On each run it compares the current sitemap against the snapshot and only submits what changed. This prevents submitting the same URLs to IndexNow every day.

  1. In the Cloudflare dashboard go to Workers & Pages → KV

  2. Click Create a namespace

  3. Name it SITEMAP_KV

  4. Click Add

Step 3: Create the Worker

  1. Go to Workers & Pages → Create

  2. Click Create a Worker

  3. Name it indexnow-yoursite (or any name you prefer)

  4. Select the Hello World template (Cloudflare requires a template, there is no blank option)

  5. Click Deploy

  6. Click Edit code

  7. Select all existing code, delete it, and paste the following:

const SITEMAP_URL = "https://yoursite.com/sitemap.xml";
const INDEXNOW_HOST = "yoursite.com";
const KV_KEY = "last_urls";

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Serve IndexNow key file
    if (url.pathname === `/${env.INDEXNOW_KEY}.txt`) {
      return new Response(env.INDEXNOW_KEY, {
        headers: { "Content-Type": "text/plain" }
      });
    }

    // Manual trigger for testing
    const result = await run(env);
    return new Response(JSON.stringify(result, null, 2), {
      headers: { "Content-Type": "application/json" }
    });
  },

  async scheduled(event, env) {
    await run(env);
  }
};

async function run(env) {
  const sitemapRes = await fetch(SITEMAP_URL);
  if (!sitemapRes.ok) {
    return { error: `Sitemap fetch failed: ${sitemapRes.status}` };
  }
  const xml = await sitemapRes.text();

  const currentUrls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)]
    .map(m => m[1].trim())
    .filter(url => url.startsWith("https://"));

  if (currentUrls.length === 0) {
    return { error: "No URLs found in sitemap" };
  }

  const stored = await env.SITEMAP_KV.get(KV_KEY);
  const previousUrls = stored ? JSON.parse(stored) : [];

  const previousSet = new Set(previousUrls);
  const currentSet = new Set(currentUrls);
  const newUrls = currentUrls.filter(url => !previousSet.has(url));
  const removedUrls = previousUrls.filter(url => !currentSet.has(url));
  const changedUrls = [...newUrls, ...removedUrls];

  let submitResult = null;
  if (changedUrls.length > 0) {
    const res = await fetch("https://api.indexnow.org/indexnow", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        host: INDEXNOW_HOST,
        key: env.INDEXNOW_KEY,
        keyLocation: `https://${INDEXNOW_HOST}/${env.INDEXNOW_KEY}.txt`,
        urlList: changedUrls
      })
    });
    submitResult = { status: res.status, new: newUrls, removed: removedUrls };
  }

  await env.SITEMAP_KV.put(KV_KEY, JSON.stringify(currentUrls));

  return {
    timestamp: new Date().toISOString(),
    totalUrls: currentUrls.length,
    newUrls: newUrls.length,
    removedUrls: removedUrls.length,
    submitted: submitResult ?? "nothing changed"
  };
}
const SITEMAP_URL = "https://yoursite.com/sitemap.xml";
const INDEXNOW_HOST = "yoursite.com";
const KV_KEY = "last_urls";

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Serve IndexNow key file
    if (url.pathname === `/${env.INDEXNOW_KEY}.txt`) {
      return new Response(env.INDEXNOW_KEY, {
        headers: { "Content-Type": "text/plain" }
      });
    }

    // Manual trigger for testing
    const result = await run(env);
    return new Response(JSON.stringify(result, null, 2), {
      headers: { "Content-Type": "application/json" }
    });
  },

  async scheduled(event, env) {
    await run(env);
  }
};

async function run(env) {
  const sitemapRes = await fetch(SITEMAP_URL);
  if (!sitemapRes.ok) {
    return { error: `Sitemap fetch failed: ${sitemapRes.status}` };
  }
  const xml = await sitemapRes.text();

  const currentUrls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)]
    .map(m => m[1].trim())
    .filter(url => url.startsWith("https://"));

  if (currentUrls.length === 0) {
    return { error: "No URLs found in sitemap" };
  }

  const stored = await env.SITEMAP_KV.get(KV_KEY);
  const previousUrls = stored ? JSON.parse(stored) : [];

  const previousSet = new Set(previousUrls);
  const currentSet = new Set(currentUrls);
  const newUrls = currentUrls.filter(url => !previousSet.has(url));
  const removedUrls = previousUrls.filter(url => !currentSet.has(url));
  const changedUrls = [...newUrls, ...removedUrls];

  let submitResult = null;
  if (changedUrls.length > 0) {
    const res = await fetch("https://api.indexnow.org/indexnow", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        host: INDEXNOW_HOST,
        key: env.INDEXNOW_KEY,
        keyLocation: `https://${INDEXNOW_HOST}/${env.INDEXNOW_KEY}.txt`,
        urlList: changedUrls
      })
    });
    submitResult = { status: res.status, new: newUrls, removed: removedUrls };
  }

  await env.SITEMAP_KV.put(KV_KEY, JSON.stringify(currentUrls));

  return {
    timestamp: new Date().toISOString(),
    totalUrls: currentUrls.length,
    newUrls: newUrls.length,
    removedUrls: removedUrls.length,
    submitted: submitResult ?? "nothing changed"
  };
}
const SITEMAP_URL = "https://yoursite.com/sitemap.xml";
const INDEXNOW_HOST = "yoursite.com";
const KV_KEY = "last_urls";

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Serve IndexNow key file
    if (url.pathname === `/${env.INDEXNOW_KEY}.txt`) {
      return new Response(env.INDEXNOW_KEY, {
        headers: { "Content-Type": "text/plain" }
      });
    }

    // Manual trigger for testing
    const result = await run(env);
    return new Response(JSON.stringify(result, null, 2), {
      headers: { "Content-Type": "application/json" }
    });
  },

  async scheduled(event, env) {
    await run(env);
  }
};

async function run(env) {
  const sitemapRes = await fetch(SITEMAP_URL);
  if (!sitemapRes.ok) {
    return { error: `Sitemap fetch failed: ${sitemapRes.status}` };
  }
  const xml = await sitemapRes.text();

  const currentUrls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)]
    .map(m => m[1].trim())
    .filter(url => url.startsWith("https://"));

  if (currentUrls.length === 0) {
    return { error: "No URLs found in sitemap" };
  }

  const stored = await env.SITEMAP_KV.get(KV_KEY);
  const previousUrls = stored ? JSON.parse(stored) : [];

  const previousSet = new Set(previousUrls);
  const currentSet = new Set(currentUrls);
  const newUrls = currentUrls.filter(url => !previousSet.has(url));
  const removedUrls = previousUrls.filter(url => !currentSet.has(url));
  const changedUrls = [...newUrls, ...removedUrls];

  let submitResult = null;
  if (changedUrls.length > 0) {
    const res = await fetch("https://api.indexnow.org/indexnow", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        host: INDEXNOW_HOST,
        key: env.INDEXNOW_KEY,
        keyLocation: `https://${INDEXNOW_HOST}/${env.INDEXNOW_KEY}.txt`,
        urlList: changedUrls
      })
    });
    submitResult = { status: res.status, new: newUrls, removed: removedUrls };
  }

  await env.SITEMAP_KV.put(KV_KEY, JSON.stringify(currentUrls));

  return {
    timestamp: new Date().toISOString(),
    totalUrls: currentUrls.length,
    newUrls: newUrls.length,
    removedUrls: removedUrls.length,
    submitted: submitResult ?? "nothing changed"
  };
}

Replace yoursite.com in the first two lines with your actual domain.

Note on sitemap format: The regex parser works with flat sitemaps. If your site uses a sitemap index file (a sitemap that points to child sitemaps), the Worker will not follow the child URLs. Most Framer sites generate a single flat sitemap. If yours does not, you will need to add a step that fetches and parses each child sitemap.

  1. Click Save and Deploy

Step 4: Bind the KV Namespace

  1. Go to your Worker → Settings → Bindings → Add binding

  2. Select KV Namespace

  3. Variable name: SITEMAP_KV

  4. KV Namespace: select SITEMAP_KV

  5. Click Add Binding

Step 5: Add the IndexNow key as a secret

  1. Go to Settings → Variables and Secrets → Add variable

  2. Type: Secret

  3. Variable name: INDEXNOW_KEY

  4. Value: paste your IndexNow key from Step 1

  5. Click Deploy

Step 6: Set up DNS and the Worker Route

IndexNow requires a verification file accessible at yoursite.com/YOUR_KEY.txt. The Worker already handles serving this file, you just need to route requests through it.

First, ensure you have a proxied record on your domain.

Worker Routes only intercept traffic that passes through Cloudflare's proxy. You need at least one proxied (orange cloud) DNS record on your domain.

Most sites: your root A record is already proxied by default in Cloudflare, nothing to do here. Check your DNS records and confirm the orange cloud is on for yourdomain.com.

Framer sites: Framer requires root A records to be DNS only for its domain verification to work. This means Cloudflare cannot intercept requests on your root domain, and the Worker Route on the root will not fire. The fix is a proxied wildcard record:

  • Type: A

  • Name: *

  • Content: your Framer site IP (visible in Framer → Custom Domain → DNS records)

  • Proxy: orange cloud (proxied)

Check if this wildcard already exists in your Cloudflare DNS, if you migrated from another provider it may already be there. Framer rejects SSL connections for unclaimed subdomains, so no content can be served under your domain by anyone else.

Then add the Worker Route:

  1. Go to Cloudflare → your domain → Workers Routes → Add route

  2. Route: yoursite.com/YOUR_KEY*

  3. Worker: select your Worker

  4. Save

Verify it works by opening https://yoursite.com/YOUR_KEY.txt in your browser. It should return just the key string as plain text.

Step 7: Add the cron trigger

  1. Go to Worker → Settings → Triggers → Cron Triggers → Add

  2. Add: 0 12 * * * (runs daily at noon UTC)

  3. Save

For sites that publish multiple times per day, add a second trigger at 0 8 * * *. You can attach up to 3 cron triggers per Worker on any plan.

Step 8: Test

  1. Go to Worker → Observability → Begin log stream

  2. Enable the workers.dev domain under Domains & Routes if it shows as Inactive

  3. In another tab open your Worker's workers.dev URL

  4. Check the logs, you should see a JSON response like this:

{
  "timestamp": "2026-04-01T12:17:12.135Z",
  "totalUrls": 13,
  "newUrls": 13,
  "removedUrls": 0,
  "submitted": {
    "status": 202,
    "new": [
      "https://yoursite.com/",
      "https://yoursite.com/about",
      "..."
    ],
    "removed": []
  }
}
{
  "timestamp": "2026-04-01T12:17:12.135Z",
  "totalUrls": 13,
  "newUrls": 13,
  "removedUrls": 0,
  "submitted": {
    "status": 202,
    "new": [
      "https://yoursite.com/",
      "https://yoursite.com/about",
      "..."
    ],
    "removed": []
  }
}
{
  "timestamp": "2026-04-01T12:17:12.135Z",
  "totalUrls": 13,
  "newUrls": 13,
  "removedUrls": 0,
  "submitted": {
    "status": 202,
    "new": [
      "https://yoursite.com/",
      "https://yoursite.com/about",
      "..."
    ],
    "removed": []
  }
}

Status 202 means IndexNow received the submission and is still validating your key file. Once the key is validated, subsequent submissions return 200. Both 200 and 202 indicate success. Run the Worker a second time, you should see "submitted": "nothing changed", confirming the diff is working correctly.

After testing, consider disabling the workers.dev domain again under Domains & Routes. Anyone who visits the URL triggers a full sitemap check, which is harmless but unnecessary.

How it works after setup

Every day at noon UTC the Worker:

  1. Fetches your sitemap

  2. Compares it against the KV snapshot from the previous run

  3. Submits new and removed URLs to IndexNow

  4. Updates the KV snapshot

On first run it submits everything. From the second run onwards it only submits what changed. If nothing changed, no request is sent to IndexNow at all.

When a page is removed from your sitemap, the Worker submits that URL to IndexNow. The search engine crawls it, finds the 404, and de-indexes it. There is no separate "deindex" call in the IndexNow protocol. This is the standard approach recommended by IndexNow.

This Worker tracks new and removed pages. It does not detect updated pages where the URL stays the same but the content changed. For platforms that include <lastmod> timestamps in their sitemaps (WordPress with Yoast, for example), the Worker could be extended to compare timestamps and submit changed URLs. Framer does not include <lastmod> in its sitemaps, so updated pages on Framer are re-crawled on the search engine's normal schedule.

Cost

Everything in this guide runs on Cloudflare's free plan:

  • Workers: 100,000 requests/day, one daily run uses 365/year

  • KV: 1 GB storage and 100,000 reads/day on free tier, a sitemap snapshot is a few KB and one read per run

  • Cron triggers: up to 3 per Worker (applies on all plans, not a free-tier limit)

Total cost: zero.

What this covers

IndexNow notifies Bing, Yandex, Naver, Seznam, Amazon, and Yep. Google is not part of the IndexNow protocol and uses a separate Indexing API. Submitting a URL does not guarantee immediate indexing, but it increases the likelihood that changes are discovered and crawled faster. For most content and business sites, the 20 minutes this setup takes is worth the improvement over waiting days or weeks for a crawler.

For full protocol details, see the IndexNow documentation and FAQ.

Related manuals