Skip to content

Commit 5fb9e28

Browse files
committed
feat(seo): IndexNow push-indexing for Bing/DDG/Yandex on prod deploys
PhotoTools' top referrer mix (PostHog 14d): ChatGPT 30, DDG 16, Bing 15+13, Google 15. Microsoft/AI ecosystem drives 4-5x Google. IndexNow surfaces new pages on Bing/DDG/Yandex/Naver within minutes vs days; Google ignores it. postbuild hook calls scripts/indexnow-push.mjs which: - Skips silently for non-production deploys (preview, local) - Errors loudly if INDEXNOW_KEY is missing in production (CI signal) - Fetches the sitemap, submits all <loc> URLs (max 10k per request) Setup needed in Vercel: add INDEXNOW_KEY scoped to Production only.
1 parent 4b8c1ed commit 5fb9e28

3 files changed

Lines changed: 84 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"scripts": {
1111
"dev": "next dev --turbopack -p 3200",
1212
"build": "next build",
13+
"postbuild": "node scripts/indexnow-push.mjs || echo 'IndexNow push skipped (INDEXNOW_KEY not set or non-prod env)'",
1314
"start": "next start -p 3200",
1415
"lint": "eslint .",
1516
"type-check": "tsc --noEmit",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
a70b97274d7146368b387682ff3cd035

scripts/indexnow-push.mjs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env node
2+
/**
3+
* IndexNow push — submit canonical URLs to Bing/DDG/Yandex/Naver after deploy.
4+
*
5+
* Bing-ecosystem search drives 4-5× more traffic to phototools.io than Google
6+
* per Apr-May 2026 PostHog referrer data, and IndexNow indexes new pages in
7+
* minutes vs days. Google ignores IndexNow but Google's not the bottleneck.
8+
*
9+
* Usage:
10+
* INDEXNOW_KEY=<key> node scripts/indexnow-push.mjs
11+
* INDEXNOW_KEY=<key> INDEXNOW_HOST=staging.example.com node scripts/indexnow-push.mjs
12+
*
13+
* Env:
14+
* INDEXNOW_KEY — required, the key string (NOT the filename). Matches
15+
* the basename of public/<key>.txt which IndexNow fetches
16+
* to verify ownership.
17+
* INDEXNOW_HOST — optional, defaults to www.phototools.io.
18+
*/
19+
20+
const HOST = process.env.INDEXNOW_HOST || 'www.phototools.io'
21+
const KEY = process.env.INDEXNOW_KEY
22+
const ENDPOINT = 'https://api.indexnow.org/indexnow'
23+
const MAX_URLS_PER_REQUEST = 10000
24+
25+
// Gate to production-only. Preview and local builds are no-ops even if KEY is set.
26+
const VERCEL_ENV = process.env.VERCEL_ENV
27+
const FORCE = process.env.INDEXNOW_FORCE === '1'
28+
if (VERCEL_ENV && VERCEL_ENV !== 'production' && !FORCE) {
29+
console.log(`Skipping IndexNow push (VERCEL_ENV=${VERCEL_ENV}, not production)`)
30+
process.exit(0)
31+
}
32+
33+
if (!KEY) {
34+
console.error('INDEXNOW_KEY env var is required')
35+
process.exit(1)
36+
}
37+
38+
async function main() {
39+
const sitemapUrl = `https://${HOST}/sitemap.xml`
40+
console.log(`Fetching sitemap: ${sitemapUrl}`)
41+
const sitemapRes = await fetch(sitemapUrl)
42+
if (!sitemapRes.ok) {
43+
console.error(`Sitemap fetch failed: ${sitemapRes.status} ${sitemapRes.statusText}`)
44+
process.exit(1)
45+
}
46+
const sitemap = await sitemapRes.text()
47+
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1])
48+
if (urls.length === 0) {
49+
console.error('No <loc> entries parsed from sitemap')
50+
process.exit(1)
51+
}
52+
console.log(`Parsed ${urls.length} URLs from sitemap`)
53+
54+
const body = {
55+
host: HOST,
56+
key: KEY,
57+
keyLocation: `https://${HOST}/${KEY}.txt`,
58+
urlList: urls.slice(0, MAX_URLS_PER_REQUEST),
59+
}
60+
61+
const res = await fetch(ENDPOINT, {
62+
method: 'POST',
63+
headers: { 'content-type': 'application/json; charset=utf-8' },
64+
body: JSON.stringify(body),
65+
})
66+
67+
const text = await res.text().catch(() => '')
68+
console.log(`IndexNow response: ${res.status} ${res.statusText}`)
69+
if (text) console.log(`Body: ${text}`)
70+
71+
// 200 = first-time success, 202 = accepted (subsequent), 422 = URL list issues
72+
if (res.status !== 200 && res.status !== 202) {
73+
console.error('IndexNow submission failed')
74+
process.exit(1)
75+
}
76+
console.log(`Submitted ${body.urlList.length} URLs for indexing`)
77+
}
78+
79+
main().catch((err) => {
80+
console.error(err)
81+
process.exit(1)
82+
})

0 commit comments

Comments
 (0)