Auto-Submit Only New Posts to IndexNow on Deploy (Bing, Naver)

How I automatically submit only new or updated post URLs to IndexNow on every deploy of a static blog by comparing lastmod in the live sitemap with the freshly built one. One submission reaches Bing, Naver, and other participating engines.

New posts show up late in search, and IndexNow

When you publish a new post, it takes a while for search engines to find it. Their crawlers (the programs that collect pages) decide on their own when to come back. I used to go into Naver Search Advisor or Bing Webmaster Tools and submit post URLs one by one, but doing that for every post was tedious and I often forgot.

IndexNow is an open protocol that automates this. The site owner tells search engines directly that "this URL is new or has changed." Submitting to one place automatically shares it with the other search engines participating in IndexNow1. Participants currently include Bing, Naver, Yandex, Seznam, and others2. Google does not participate.

So I added IndexNow submission to this blog's deploy command, with two goals:

  • Submit automatically on deploy.
  • Submit only new or updated posts, not every URL. Re-submitting unchanged URLs every time is just noise for search engines.

Preparing the IndexNow key file

To submit URLs via IndexNow, you need a key that proves you own the site. The key is a string you make up yourself: 8 to 128 characters of letters, digits, and -1. I used a 32-character hex string, which you can generate in a terminal like this:

openssl rand -hex 16

The key goes in two places:

  1. A key file at the site root: put only the key string, on one line, in public/<IndexNowKey>.txt. Once deployed, it's served at https://sncstudio.net/<IndexNowKey>.txt. Search engines open this file to check the key.
  2. A constant for the submit script: the same value goes in src/lib/env.ts.
export const INDEXNOW_KEY = '<IndexNowKey>'; // public/<키>.txt 와 같아야 함

The key file is public, so there's no need to hide it like a password. But if the two values differ, submissions are rejected (403).

Finding changed posts with sitemap lastmod

The key question was how to tell which posts are "new or updated." Instead of keeping a separate record, I used the sitemap.

This blog's post sitemap (post-sitemap.xml) lists each post's URL (loc) and last-modified time (lastmod). lastmod comes from the post's frontmatter updatedAt.

<url><loc>https://sncstudio.net/korea-covered-call-etf-comparison</loc><lastmod>2026-10-20T11:10:00.000Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>

Right before a deploy, there are two sitemaps:

  • The live sitemap: what's currently on the site, from the previous deploy
  • The freshly built sitemap: out/post-sitemap.xml, what's about to be deployed

Comparing them, a URL missing from the live one is a new post, and a URL whose lastmod changed is an updated post. So if you edit an existing post and bump its updatedAt, it gets resubmitted automatically.

The full scripts/deploy.ts

The deploy command in package.json builds the site and then runs this script:

"deploy": "bun run build && bun scripts/deploy.ts",

Here's the full scripts/deploy.ts:

// out/을 Cloudflare Pages에 배포하고, 새로 추가·수정된 포스트 URL을 IndexNow로 알린다.
// 배포 전 운영 중인 post-sitemap.xml과 새로 빌드한 out/post-sitemap.xml의 lastmod를 비교해 바뀐 URL만 보낸다.
// IndexNow는 네이버·Bing 등 참여 검색엔진에 공유된다(구글은 미지원).
import {readFileSync} from "node:fs";
import {spawnSync} from "node:child_process";
import {INDEXNOW_KEY, SITE_URL} from "../src/lib/env";

const SITEMAP_PATH = '/post-sitemap.xml';

function parseSitemap(xml: string): Map<string, string> {
  const entries = new Map<string, string>();
  for (const [, body] of xml.matchAll(/<url>([\s\S]*?)<\/url>/g)) {
    const loc = body.match(/<loc>([^<]+)<\/loc>/)?.[1];
    const lastmod = body.match(/<lastmod>([^<]+)<\/lastmod>/)?.[1] ?? '';
    if (loc) entries.set(loc, lastmod);
  }
  return entries;
}

async function fetchLiveSitemap(): Promise<Map<string, string> | null> {
  try {
    const res = await fetch(`${SITE_URL}${SITEMAP_PATH}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return parseSitemap(await res.text());
  } catch (e) {
    console.warn(`[indexnow] 운영 사이트맵을 가져오지 못해 IndexNow 전송을 건너뜁니다: ${e}`);
    return null;
  }
}

async function submitIndexNow(urls: string[]) {
  const res = await fetch('https://api.indexnow.org/indexnow', {
    method: 'POST',
    headers: {'Content-Type': 'application/json; charset=utf-8'},
    body: JSON.stringify({
      host: new URL(SITE_URL).host,
      key: INDEXNOW_KEY,
      keyLocation: `${SITE_URL}/${INDEXNOW_KEY}.txt`,
      urlList: urls,
    }),
  });
  // 200: 접수, 202: 접수(키 검증 대기), 403: 키 불일치, 422: host와 URL 불일치
  console.log(`[indexnow] 응답 ${res.status} ${res.statusText}`);
  if (!res.ok) console.log(await res.text());
}

const live = await fetchLiveSitemap();
const built = parseSitemap(readFileSync(`out${SITEMAP_PATH}`, 'utf8'));
const changed = live ? [...built].filter(([loc, lastmod]) => live.get(loc) !== lastmod).map(([loc]) => loc) : [];

const deploy = spawnSync(
  'wrangler',
  ['pages', 'deploy', 'out', '--project-name=sncstudio-blog', '--branch=main', '--commit-dirty=true'],
  {stdio: 'inherit'},
);
if (deploy.status !== 0) process.exit(deploy.status ?? 1);

if (changed.length === 0) {
  console.log('[indexnow] 새로 추가·수정된 포스트가 없어 전송하지 않습니다.');
} else {
  console.log(`[indexnow] ${changed.length}개 URL 전송:\n  ${changed.join('\n  ')}`);
  await submitIndexNow(changed);
}

Let's go through the highlighted parts.

Fetch the live sitemap before deploying

Order matters. Once the deploy finishes, the live sitemap is replaced by the new one and there's nothing left to compare against. So the script fetches the live sitemap before deploying and computes the list of changed URLs (changed) up front.

If the live sitemap can't be fetched (a network error, for example), submission is skipped. Without a baseline, skipping once seemed safer than submitting everything.

Submit only after a successful deploy

The deploy uses Cloudflare's wrangler pages deploy command. If it fails, the script exits right there. Announcing URLs that weren't deployed would send search engines to pages that don't exist yet (404).

Send everything at once to api.indexnow.org

https://api.indexnow.org/indexnow is the shared IndexNow endpoint3. The script POSTs these values as JSON:

  • host: the site's host (sncstudio.net)
  • key: the IndexNow key
  • keyLocation: the URL of the key file
  • urlList: the changed post URLs (up to 10,000 per request)

The response codes mean1:

CodeMeaning
200Submitted successfully
202Received, key validation pending
400Invalid request format
403Invalid key (key file not found or value mismatch)
422URLs don't match host
429Too many requests

What actually got submitted on deploy

A single bun run deploy builds, deploys, and submits to IndexNow in order. If nothing changed, it just prints [indexnow] 새로 추가·수정된 포스트가 없어 전송하지 않습니다. ("no new or updated posts, nothing submitted") and exits.

Things to keep in mind

  • Google doesn't use IndexNow. For Google, register your sitemap in Search Console and let the crawler pick it up.
  • You need to bump updatedAt for edits to be submitted. If you change the content but leave updatedAt alone, lastmod stays the same and the post isn't seen as changed. On the other hand, bumping it for every typo fix triggers a submission each time, so it's better to bump it only for meaningful edits.
  • Sitemap URLs must match the real URLs. If the sitemap lists URLs with a trailing slash that actually redirect, you'll be announcing redirecting URLs to search engines. I covered how to keep URLs consistent in How to Stop Cloudflare Pages Trailing Slash Redirects.
  • This blog's Korean, English, and Japanese posts all live in one post-sitemap.xml, so new or updated translations are submitted the same way.

References

Footnotes

  1. https://www.indexnow.org/documentation ↩ ↩2 ↩3

  2. https://www.indexnow.org/searchengines.json ↩

  3. https://www.indexnow.org/faq ↩

2026 © snc studio·Privacy Policy