Imported from isaachorowitz/multi-agent-setup (
growth-skills/seo/seo-api-integrations/SKILL.md). Install upstream withnpx skills add isaachorowitz/multi-agent-setup --skill seo-api-integrations. Copyright stays with the author.
Complete SEO API Setup Guide — Search Console, Analytics, Bing & More
A full, reusable walkthrough for connecting every major SEO API to a Next.js site. Covers Google Search Console, Google Indexing API, Google Analytics (GA4), PageSpeed Insights, IndexNow, and Bing Webmaster Tools. Written to be handed to anyone — no prior experience required.
Table of Contents
- Prerequisites — What You Need Before Starting
- Install All Dependencies
- Google Cloud Service Account Setup
- Enable Required Google APIs
- Add Service Account to Search Console
- Add Service Account to GA4 & Find Your Property ID
- Set Up Bing Webmaster Tools
- Set Up IndexNow (Bing, Yandex & Others)
- Get a PageSpeed Insights API Key
- Environment Variables — Complete Template
- Verify Everything Works
- Google Search Console API (
lib/search-console.ts) - Google Indexing API (
lib/indexing.ts) - IndexNow (
lib/indexnow.ts) - PageSpeed Insights API (
lib/pagespeed.ts) - Google Analytics Data API — GA4 (
lib/ga4.ts) - GA4 Admin API — Configure Conversions
- Bing Webmaster Tools API (
lib/bing-webmaster.ts) - SEO Analysis Engine (
lib/seo-analysis.ts) - API Routes (Next.js App Router)
- Scripts — Automation & Maintenance
- On-Page SEO Best Practices
- Canonical / www Setup
- What Data Each API Gives You
- SEO Analysis Patterns — Acting on the Data
- Quick Reference — All Endpoints
1. Prerequisites — What You Need Before Starting
Before touching any code, make sure you have accounts and access to all of the following. This is the checklist to complete first.
Accounts required
- Google account — the one you use for Google Search Console and Google Analytics
- Google Cloud Console access — console.cloud.google.com (free, uses the same Google account)
- Google Search Console — site must already be verified at search.google.com/search-console
- Google Analytics (GA4) — a GA4 property must already be set up and receiving data at analytics.google.com
- Bing Webmaster Tools — site must already be verified at bing.com/webmasters
Your site setup
- Next.js project (App Router) with TypeScript
- Site deployed and publicly accessible (needed for IndexNow verification)
- You know your site's canonical domain (e.g.
www.yourdomain.comvsyourdomain.com) — check your hosting/Vercel settings to see which version your site redirects to
Tools on your machine
- Node.js 18+ and npm/pnpm/yarn
- A terminal / command line
- A code editor
2. Install All Dependencies
Run this in your project root. This is the only npm package needed — it covers Search Console, Indexing API, GA4 Data API, and GA4 Admin API all in one.
npm install googleapis
Everything else (PageSpeed Insights, IndexNow, Bing Webmaster) uses the built-in fetch API — no additional packages required.
Also install dotenv and tsx if you want to run the automation scripts locally:
npm install --save-dev dotenv tsx
Why only one package? The googleapis npm package is Google's official client library. It handles OAuth token refresh, retry logic, and TypeScript types for every Google API under one import: import { google } from 'googleapis'.
Verify the install worked:
node -e "const { google } = require('googleapis'); console.log('googleapis version:', require('./node_modules/googleapis/package.json').version)"
3. Google Cloud Service Account Setup
A service account is like a "robot user" — it's a Google identity that your server code uses to authenticate with Google APIs without requiring a human to log in. One service account handles all Google APIs for your project.
Step-by-step
-
Go to console.cloud.google.com
-
Create or select a project
- Click the project dropdown at the top → New Project
- Name it something clear (e.g.
mysite-seo) - Note the Project ID (shown below the project name) — you'll need it in URLs later
- Click Create
-
Create the service account
- In the left sidebar: APIs & Services → Credentials
- Click + Create Credentials → Service Account
- Name:
seo-service-account(or anything descriptive) - Description: "SEO API access for [your site]"
- Click Create and Continue
- Skip the optional "Grant this service account access" and "Grant users access" steps
- Click Done
-
Download the JSON key
- You'll see your new service account in the list — click its email address
- Click the Keys tab
- Add Key → Create new key → JSON
- Click Create — a JSON file downloads automatically
- Keep this file safe and never commit it to git
-
Extract the two values you need from the JSON file Open the downloaded JSON. It looks like this:
{ "type": "service_account", "project_id": "your-project-id", "client_email": "seo-service-account@your-project.iam.gserviceaccount.com", "private_key": "<PEM_PRIVATE_KEY_PLACEHOLDER>", ... }Copy
client_emailandprivate_key— these go into.env.local.
Important — private key formatting:
The private_key value in the JSON has literal \n characters (backslash + n) representing line breaks. When you paste it into .env.local, wrap it in double quotes and keep those \n characters exactly as-is. Do not convert them to actual newlines. The code handles the conversion at runtime with .replace(/\\n/g, '\n').
✅ Correct in .env.local:
GOOGLE_PRIVATE_KEY="<PEM_PRIVATE_KEY_PLACEHOLDER>"
❌ Wrong (will cause auth failures):
GOOGLE_PRIVATE_KEY=<PEM_PRIVATE_KEY_PLACEHOLDER>
4. Enable Required Google APIs
Your service account can only call APIs that are explicitly enabled in your Google Cloud project. You must enable each one manually.
How to enable an API
- Go to console.cloud.google.com/apis/library
- Make sure your project is selected in the top dropdown
- Search for the API name
- Click it → click Enable
APIs to enable (enable all of these)
| API Name | What to search | Used for |
|---|---|---|
| Google Search Console API | "Search Console" | Query data, sitemaps, URL inspection |
| Web Search Indexing API | "Indexing API" | Submit URLs to Google for crawling |
| Google Analytics Data API | "Analytics Data" | GA4 traffic, sessions, conversions |
| Google Analytics Admin API | "Analytics Admin" | Configure conversion events, property settings |
| PageSpeed Insights API | "PageSpeed Insights" | Lighthouse scores, Core Web Vitals |
After enabling each API, wait about 2 minutes before testing — it takes time to propagate. If you get a "has not been used in project" error when you first call an API, you either forgot to enable it or hit this propagation delay.
Quick-enable URLs
Replace YOUR_PROJECT_ID with your actual project ID:
https://console.cloud.google.com/apis/library/searchconsole.googleapis.com?project=YOUR_PROJECT_ID
https://console.cloud.google.com/apis/library/indexing.googleapis.com?project=YOUR_PROJECT_ID
https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com?project=YOUR_PROJECT_ID
https://console.cloud.google.com/apis/library/analyticsadmin.googleapis.com?project=YOUR_PROJECT_ID
https://console.cloud.google.com/apis/library/pagespeedonline.googleapis.com?project=YOUR_PROJECT_ID
5. Add Service Account to Search Console
Google Search Console doesn't automatically trust your service account — you have to explicitly grant it access to your property.
Step-by-step
- Go to search.google.com/search-console
- Select your property from the left sidebar
- Click Settings (gear icon in the left sidebar, near the bottom)
- Click Users and permissions
- Click Add user (top right)
- In the email field, paste your service account's
client_email- It looks like:
seo-service-account@your-project.iam.gserviceaccount.com
- It looks like:
- Set permission level to Owner
- Owner is required for sitemap submission and full data access
- "Full user" only gives read access — not enough for submitting sitemaps
- Click Add
Domain property vs URL prefix — which to use
Search Console has two property types. Always prefer the domain property:
| Type | Format for env var | Covers |
|---|---|---|
| Domain property (preferred) | sc-domain:yourdomain.com |
All subdomains (www, m, etc.) + http + https |
| URL prefix | https://www.yourdomain.com/ |
Only that exact URL prefix |
If you're not sure which type your property is: in Search Console, look at the property name in the sidebar. If it just shows yourdomain.com (no https://), it's a domain property. If it shows https://www.yourdomain.com/, it's a URL prefix property.
Set your env var accordingly:
# Domain property:
GOOGLE_SEARCH_CONSOLE_SITE_URL=sc-domain:yourdomain.com
# URL prefix property:
GOOGLE_SEARCH_CONSOLE_SITE_URL=https://www.yourdomain.com/
6. Add Service Account to GA4 & Find Your Property ID
Add the service account
- Go to analytics.google.com
- Click the Admin gear icon (bottom left)
- In the Property column (middle column), click Property Access Management
- Click the + button (top right) → Add users
- Enter the service account
client_email - Set the role to Viewer
- Viewer is sufficient for all Data API reads (pulling reports)
- You only need Editor/Admin if you want to modify GA4 settings via the Admin API
- Uncheck "Notify new users by email" (the service account doesn't have an inbox)
- Click Add
Find your GA4 Property ID (manual method)
- In GA4 Admin, click Property Settings (in the Property column)
- At the top you'll see Property ID — a plain number like
000000000 - Add
properties/before it for the env var:GA4_PROPERTY_ID=properties/000000000
Find your GA4 Property ID (via API — useful if you have multiple properties)
Once your service account is set up and googleapis is installed, you can list all GA4 properties programmatically. Run this one-time script from your project root:
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
const { google } = require('googleapis')
async function main() {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\\\/n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
})
const admin = google.analyticsadmin({ version: 'v1alpha', auth })
const res = await admin.properties.list({ filter: 'parent:accounts/-' })
const props = res.data.properties ?? []
if (!props.length) {
console.log('No properties found. Check that the service account has been added to GA4.')
return
}
props.forEach(p => {
const id = p.name?.replace('properties/', '')
console.log('Property ID:', id, '|', p.displayName, '|', p.industryCategory)
console.log(' Set: GA4_PROPERTY_ID=' + p.name)
})
}
main().catch(e => console.error('Error:', e.message))
"
This lists every GA4 property your service account has access to. Copy the GA4_PROPERTY_ID=properties/XXXXXXXXX line for the correct property.
7. Set Up Bing Webmaster Tools
Bing has its own completely separate webmaster platform. Your Google Search Console data tells you nothing about Bing rankings — you need this separately.
Verify your site in Bing (if not already done)
- Go to bing.com/webmasters and sign in with a Microsoft account
- Click Add a site → enter your site URL
- Choose a verification method:
- XML file (easiest): download the file, place it in your
/publicfolder, deploy, then verify - Meta tag: add a meta tag to your homepage
<head>, deploy, then verify - CNAME: add a DNS record (no code deploy needed)
- XML file (easiest): download the file, place it in your
Get your Bing API key
- In Bing Webmaster Tools, click Settings (gear icon, top right)
- Click API Access
- Click Generate API Key
- Copy the key — it looks like
6be23b5957a04486893c08ca8f95560f
Note your verified site URL exactly
The BING_SITE_URL env var must match exactly how your site is registered in Bing — including trailing slash and www/non-www. Check the URL shown in your Bing Webmaster dashboard.
# If Bing shows https://www.yourdomain.com/:
BING_SITE_URL=https://www.yourdomain.com/
# If Bing shows https://yourdomain.com/:
BING_SITE_URL=https://yourdomain.com/
8. Set Up IndexNow (Bing, Yandex & Others)
IndexNow is an open protocol — one submission notifies Bing, Yandex, Seznam, and Naver simultaneously. It's separate from the Bing Webmaster API. Think of it as "push indexing" for multiple search engines at once.
Step 1: Generate a key
Run this in your terminal:
openssl rand -hex 16
This outputs a 32-character hex string like <your-indexnow-key>. This is your INDEXNOW_KEY.
If you don't have openssl, you can generate one online at any random hex generator — just make sure it's at least 32 characters, only letters a–f and numbers 0–9.
Step 2: Create the verification file
Create a file at public/[YOUR_KEY].txt (replace with your actual key):
public/<your-indexnow-key>.txt
The entire contents of this file should be just the key string and nothing else:
<your-indexnow-key>
No quotes, no spaces, no newline at the end. Just the key.
Step 3: Deploy and verify
After deploying, confirm the file is accessible:
https://www.yourdomain.com/[YOUR_KEY].txt
It should return just the key string. If it returns a 404 or an HTML page, the verification will fail.
Step 4: Set the env var
INDEXNOW_KEY=<your-indexnow-key>
9. Get a PageSpeed Insights API Key
PageSpeed Insights (PSI) works without a key, but the anonymous quota is only about 2 requests per day — completely unusable for any real workflow. An API key gives you ~25,000 requests per day for free.
Step-by-step
- Go to console.cloud.google.com/apis/credentials
- Make sure your project is selected
- Click + Create Credentials → API Key
- A key is generated immediately — copy it
- Click Edit API Key (pencil icon) to restrict it:
- Under API restrictions, select Restrict key
- Choose PageSpeed Insights API from the dropdown
- Click Save
- Restricting it is a security best practice — if the key leaks, it can only be used for PSI
GOOGLE_PSI_API_KEY=AIzaSy...
Note: This API key is different from the service account credentials. API keys are simple strings; service accounts are full identity credentials with JSON key files. Both are needed.
10. Environment Variables — Complete Template
Create a file called .env.local in your project root. Never commit this file to git — add it to .gitignore if it isn't already there.
# ─── Google Service Account (shared across ALL Google APIs) ──────────────────
# From the JSON key file you downloaded in Step 3
GOOGLE_SERVICE_ACCOUNT_EMAIL=seo-service-account@your-project.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="<PEM_PRIVATE_KEY_PLACEHOLDER>"
# ─── Google Search Console ────────────────────────────────────────────────────
# Domain property (preferred): sc-domain:yourdomain.com
# URL prefix property (fallback): https://www.yourdomain.com/
GOOGLE_SEARCH_CONSOLE_SITE_URL=sc-domain:yourdomain.com
# ─── Google Analytics GA4 ────────────────────────────────────────────────────
# Found in GA4 Admin → Property Settings → Property ID
# Always include the "properties/" prefix
GA4_PROPERTY_ID=properties/000000000
# ─── PageSpeed Insights ───────────────────────────────────────────────────────
# From console.cloud.google.com/apis/credentials
# Without this key, you're limited to ~2 anonymous requests per day
GOOGLE_PSI_API_KEY=AIzaSy...
# ─── IndexNow (Bing, Yandex, Seznam, Naver) ──────────────────────────────────
# Generate with: openssl rand -hex 16
# Must also create: public/[YOUR_KEY].txt containing just the key string
INDEXNOW_KEY=your32charhexkeyhere
# ─── Bing Webmaster Tools ────────────────────────────────────────────────────
# From bing.com/webmasters → Settings → API Access → Generate API Key
BING_WEBMASTER_API_KEY=your-bing-api-key
# Must match EXACTLY how the site appears in your Bing Webmaster dashboard (including trailing slash)
BING_SITE_URL=https://www.yourdomain.com/
Checklist before moving on:
-
GOOGLE_SERVICE_ACCOUNT_EMAIL— copied from service account JSON -
GOOGLE_PRIVATE_KEY— copied from service account JSON, wrapped in double quotes,\nkept as-is -
GOOGLE_SEARCH_CONSOLE_SITE_URL— usessc-domain:prefix for domain properties -
GA4_PROPERTY_ID— starts withproperties/followed by the numeric ID -
GOOGLE_PSI_API_KEY— starts withAIzaSy -
INDEXNOW_KEY— 32-char hex string, matching file created inpublic/ -
BING_WEBMASTER_API_KEY— from Bing Webmaster Tools settings -
BING_SITE_URL— trailing slash, matches Bing dashboard exactly
11. Verify Everything Works
Before writing any application code, run these one-liner tests to confirm each credential and API connection is working. Run them from your project root after creating .env.local.
Test 1: Google service account authentication
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
const { google } = require('googleapis')
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\\\/n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/webmasters.readonly'],
})
auth.getClient().then(() => console.log('✅ Service account auth working'))
.catch(e => console.error('❌ Auth failed:', e.message))
"
Test 2: Google Search Console
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
const { google } = require('googleapis')
async function main() {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\\\/n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/webmasters.readonly'],
})
const sc = google.searchconsole({ version: 'v1', auth })
const res = await sc.sites.list()
console.log('✅ Search Console working. Sites:', res.data.siteEntry?.map(s => s.siteUrl).join(', '))
}
main().catch(e => console.error('❌', e.message))
"
Test 3: GA4 — list properties and confirm property ID
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
const { google } = require('googleapis')
async function main() {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\\\/n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
})
const admin = google.analyticsadmin({ version: 'v1alpha', auth })
const res = await admin.properties.list({ filter: 'parent:accounts/-' })
const props = res.data.properties ?? []
if (!props.length) return console.log('⚠️ No GA4 properties found — check service account access in GA4')
props.forEach(p => console.log('✅ GA4 property:', p.name, '|', p.displayName))
}
main().catch(e => console.error('❌', e.message))
"
Test 4: GA4 Data API — pull last 7 days
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
const { google } = require('googleapis')
async function main() {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\\\/n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
})
const client = google.analyticsdata({ version: 'v1beta', auth })
const res = await client.properties.runReport({
property: process.env.GA4_PROPERTY_ID,
requestBody: {
dateRanges: [{ startDate: '7daysAgo', endDate: 'today' }],
dimensions: [{ name: 'deviceCategory' }],
metrics: [{ name: 'sessions' }],
},
})
console.log('✅ GA4 Data API working')
res.data.rows?.forEach(r => console.log(' ', r.dimensionValues?.[0]?.value, ':', r.metricValues?.[0]?.value, 'sessions'))
}
main().catch(e => console.error('❌', e.message))
"
Test 5: PageSpeed Insights
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
async function main() {
const url = 'https://www.yourdomain.com/'
const key = process.env.GOOGLE_PSI_API_KEY
const res = await fetch('https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=' + encodeURIComponent(url) + '&strategy=mobile&key=' + key)
const data = await res.json()
if (data.error) return console.error('❌', data.error.message)
const score = Math.round(data.lighthouseResult.categories.performance.score * 100)
console.log('✅ PageSpeed working. Mobile performance score:', score)
}
main().catch(e => console.error('❌', e.message))
"
Test 6: Bing Webmaster API
npx tsx -e "
require('dotenv').config({ path: '.env.local' })
async function main() {
const res = await fetch('https://ssl.bing.com/webmaster/api.svc/json/GetUserSites?apikey=' + process.env.BING_WEBMASTER_API_KEY, {
headers: { 'Content-Type': 'application/json; charset=utf-8' }
})
const data = await res.json()
if (data.ErrorCode) return console.error('❌ Bing error:', data.Message)
const sites = data.d ?? []
console.log('✅ Bing Webmaster working. Sites:', sites.map(s => s.Url).join(', '))
}
main().catch(e => console.error('❌', e.message))
"
Test 7: IndexNow verification file
# Replace with your actual key and domain
curl https://www.yourdomain.com/YOUR_INDEXNOW_KEY.txt
# Should return just the key string, nothing else
All 7 tests should show ✅ before proceeding. Fix any failures now — they will not fix themselves later.
12. Google Search Console API
lib/search-console.ts
import { google } from 'googleapis'
// Read env vars lazily (inside functions, not at module top level)
// This prevents failures when the module is imported before dotenv has run
const getSiteUrl = () => process.env.GOOGLE_SEARCH_CONSOLE_SITE_URL!
function getAuth() {
return new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: [
'https://www.googleapis.com/auth/webmasters.readonly',
'https://www.googleapis.com/auth/webmasters',
],
})
}
function getSearchConsole() {
return google.searchconsole({ version: 'v1', auth: getAuth() })
}
export type Dimension = 'query' | 'page' | 'country' | 'device' | 'date' | 'searchAppearance'
export interface SearchAnalyticsOptions {
startDate: string // MUST be YYYY-MM-DD — GSC does NOT accept '28daysAgo' (that's GA4 syntax)
endDate: string // MUST be YYYY-MM-DD
dimensions?: Dimension[]
rowLimit?: number // Max 25,000
startRow?: number // For pagination through large datasets
dimensionFilterGroups?: object[]
}
export async function getSearchAnalytics(options: SearchAnalyticsOptions) {
const sc = getSearchConsole()
const res = await sc.searchanalytics.query({
siteUrl: getSiteUrl(),
requestBody: {
startDate: options.startDate,
endDate: options.endDate,
dimensions: options.dimensions ?? ['query'],
rowLimit: options.rowLimit ?? 1000,
startRow: options.startRow ?? 0,
dimensionFilterGroups: options.dimensionFilterGroups as any,
},
})
return res.data.rows ?? []
}
export async function listSitemaps() {
const sc = getSearchConsole()
const res = await sc.sitemaps.list({ siteUrl: getSiteUrl() })
return res.data.sitemap ?? []
}
export async function submitSitemap(feedpath: string) {
const sc = getSearchConsole()
await sc.sitemaps.submit({ siteUrl: getSiteUrl(), feedpath })
return { submitted: feedpath }
}
export async function deleteSitemap(feedpath: string) {
const sc = getSearchConsole()
await sc.sitemaps.delete({ siteUrl: getSiteUrl(), feedpath })
return { deleted: feedpath }
}
export async function inspectUrl(inspectionUrl: string) {
const sc = getSearchConsole()
const res = await sc.urlInspection.index.inspect({
requestBody: { inspectionUrl, siteUrl: getSiteUrl() },
})
return res.data
}
export async function listSites() {
const sc = getSearchConsole()
const res = await sc.sites.list()
return res.data.siteEntry ?? []
}
export async function getSite() {
const sc = getSearchConsole()
const res = await sc.sites.get({ siteUrl: getSiteUrl() })
return res.data
}
Converting relative dates for GSC (copy this helper wherever you call GSC):
function toDate(relative?: string): string {
if (!relative || relative === 'today') return new Date().toISOString().slice(0, 10)
const m = relative.match(/^(\d+)daysAgo$/)
if (m) {
const d = new Date()
d.setDate(d.getDate() - parseInt(m[1]))
return d.toISOString().slice(0, 10)
}
return relative // already YYYY-MM-DD
}
// Usage:
getSearchAnalytics({ startDate: toDate('90daysAgo'), endDate: toDate('today'), dimensions: ['query'] })
13. Google Indexing API
lib/indexing.ts
import { google } from 'googleapis'
function getAuth() {
return new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/indexing'],
})
}
export type IndexingType = 'URL_UPDATED' | 'URL_DELETED'
export interface IndexingResult {
url: string
type: IndexingType
notifyTime?: string
error?: string
}
export async function notifyUrl(url: string, type: IndexingType = 'URL_UPDATED'): Promise<IndexingResult> {
try {
const auth = getAuth()
const client = await auth.getClient()
const accessToken = await client.getAccessToken()
const res = await fetch('https://indexing.googleapis.com/v3/urlNotifications:publish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken.token}`,
},
body: JSON.stringify({ url, type }),
})
const data = await res.json()
if (!res.ok) return { url, type, error: data.error?.message ?? `HTTP ${res.status}` }
// notifyTime is not always present in the response — absence of 'error' means success
return { url, type, notifyTime: data.urlNotificationMetadata?.latestUpdate?.notifyTime }
} catch (err: any) {
return { url, type, error: err.message }
}
}
// Submit multiple URLs — Google allows 200 requests/day per project
export async function notifyUrls(urls: string[], type: IndexingType = 'URL_UPDATED'): Promise<IndexingResult[]> {
return Promise.all(urls.map(url => notifyUrl(url, type)))
}
export async function getUrlStatus(url: string) {
const auth = getAuth()
const client = await auth.getClient()
const accessToken = await client.getAccessToken()
const res = await fetch(
`https://indexing.googleapis.com/v3/urlNotifications/metadata?url=${encodeURIComponent(url)}`,
{ headers: { Authorization: `Bearer ${accessToken.token}` } }
)
return res.json()
}
Key notes:
- 200 requests/day hard limit. Each URL = 1 request. Submit highest-priority pages first.
- No
errorin the response = success, even ifnotifyTimeis absent - Officially documented only for
JobPostingandBroadcastEventschema pages, but widely used and works for all page types in practice - Use
URL_DELETEDwhen permanently removing a page — signals Google to drop it from the index faster - This does not guarantee immediate indexing — it tells Google to crawl the page soon
14. IndexNow
lib/indexnow.ts
// Update HOST to match your canonical domain (no https://, no trailing slash)
const HOST = 'www.yourdomain.com'
const KEY = () => process.env.INDEXNOW_KEY!
const KEY_LOCATION = `https://${HOST}/${KEY()}.txt`
export async function submitToIndexNow(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: HOST,
key: KEY(),
keyLocation: KEY_LOCATION,
urlList: urls,
}),
})
return {
status: res.status,
ok: res.ok,
message: res.status === 202 ? 'Accepted' : await res.text(),
}
}
Response codes:
202— Accepted for processing ✅ (normal success)200— Already known, no action needed ✅403— Key file not found atkeyLocationURL — checkpublic/[key].txtis deployed422— URLs don't match thehost— all submitted URLs must be on the same domain asHOST429— Too many requests — rare, but back off and retry
No daily limit. Submit up to 10,000 URLs per request.
15. PageSpeed Insights API
lib/pagespeed.ts
export type PSIStrategy = 'mobile' | 'desktop'
export interface PSIResult {
url: string
strategy: PSIStrategy
scores: {
performance: number | null // 0–100
seo: number | null // 0–100
accessibility: number | null // 0–100
bestPractices: number | null // 0–100
}
metrics: {
lcp: string | null // Largest Contentful Paint — target: < 2.5s
cls: string | null // Cumulative Layout Shift — target: < 0.1
tbt: string | null // Total Blocking Time (proxy for INP) — target: < 200ms
fcp: string | null // First Contentful Paint — target: < 1.8s
si: string | null // Speed Index
ttfb: string | null // Time to First Byte — target: < 800ms
}
failedAudits: Array<{ id: string; title: string; score: number; displayValue: string }>
error?: string
}
export async function runPageSpeed(
url: string,
strategy: PSIStrategy = 'mobile',
categories = ['performance', 'seo', 'accessibility', 'best-practices']
): Promise<PSIResult> {
const apiKey = process.env.GOOGLE_PSI_API_KEY
const params = new URLSearchParams({ url, strategy, ...(apiKey ? { key: apiKey } : {}) })
categories.forEach(c => params.append('category', c))
const base: PSIResult = {
url, strategy,
scores: { performance: null, seo: null, accessibility: null, bestPractices: null },
metrics: { lcp: null, cls: null, tbt: null, fcp: null, si: null, ttfb: null },
failedAudits: [],
}
try {
const res = await fetch(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${params}`)
const data = await res.json()
if (data.error) return { ...base, error: data.error.message }
const cats = data.lighthouseResult?.categories ?? {}
const audits = data.lighthouseResult?.audits ?? {}
const score = (key: string) =>
cats[key]?.score != null ? Math.round(cats[key].score * 100) : null
const val = (key: string) => audits[key]?.displayValue ?? null
const failedAudits = Object.entries(audits)
.filter(([, a]: any) => a.score !== null && a.score < 0.9)
.sort(([, a]: any, [, b]: any) => (a.score ?? 1) - (b.score ?? 1))
.slice(0, 10)
.map(([id, a]: any) => ({
id, title: a.title,
score: Math.round((a.score ?? 0) * 100),
displayValue: a.displayValue ?? '',
}))
return {
url, strategy,
scores: {
performance: score('performance'),
seo: score('seo'),
accessibility: score('accessibility'),
bestPractices: score('best-practices'),
},
metrics: {
lcp: val('largest-contentful-paint'),
cls: val('cumulative-layout-shift'),
tbt: val('total-blocking-time'),
fcp: val('first-contentful-paint'),
si: val('speed-index'),
ttfb: val('server-response-time'),
},
failedAudits,
}
} catch (err: any) {
return { ...base, error: err.message }
}
}
export async function runPageSpeedBoth(url: string) {
const [mobile, desktop] = await Promise.all([
runPageSpeed(url, 'mobile'),
runPageSpeed(url, 'desktop'),
])
return { mobile, desktop }
}
Always run mobile first. Google uses mobile-first indexing — your mobile score is what matters for rankings. Desktop score is informational only.
Core Web Vitals targets:
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1 – 0.25 | > 0.25 |
| INP / TBT (Interaction to Next Paint) | < 200ms | 200ms – 500ms | > 500ms |
16. Google Analytics Data API — GA4
lib/ga4.ts
import { google } from 'googleapis'
const PROPERTY_ID = process.env.GA4_PROPERTY_ID ?? 'properties/000000000'
function getAuth() {
return new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
})
}
function getClient() {
return google.analyticsdata({ version: 'v1beta', auth: getAuth() })
}
export interface ReportOptions {
startDate?: string // GA4 accepts relative: '28daysAgo', '7daysAgo', 'today', or YYYY-MM-DD
endDate?: string
dimensions: string[]
metrics: string[]
orderBy?: { field: string; type: 'dimension' | 'metric'; desc?: boolean }
limit?: number
}
export async function runReport(options: ReportOptions) {
const client = getClient()
// Type cast required — googleapis overloads don't resolve cleanly for this method
const res = await (client.properties.runReport as any)({
property: PROPERTY_ID,
requestBody: {
dateRanges: [{
startDate: options.startDate ?? '28daysAgo',
endDate: options.endDate ?? 'today',
}],
dimensions: options.dimensions.map(name => ({ name })),
metrics: options.metrics.map(name => ({ name })),
orderBys: options.orderBy ? [{
[options.orderBy.type === 'metric' ? 'metric' : 'dimension']: {
[options.orderBy.type === 'metric' ? 'metricName' : 'dimensionName']: options.orderBy.field,
},
desc: options.orderBy.desc ?? true,
}] : undefined,
limit: String(options.limit ?? 100), // ⚠️ GA4 API requires limit as string, not number
},
})
const dimHeaders: string[] = (res.data.dimensionHeaders ?? []).map((h: any) => h.name as string)
const metHeaders: string[] = (res.data.metricHeaders ?? []).map((h: any) => h.name as string)
return (res.data.rows ?? []).map((row: any) => {
const obj: Record<string, string> = {}
dimHeaders.forEach((k: string, i: number) => { obj[k] = row.dimensionValues?.[i]?.value ?? '' })
metHeaders.forEach((k: string, i: number) => { obj[k] = row.metricValues?.[i]?.value ?? '0' })
return obj
})
}
// Pre-built reports — use these directly or use runReport() for custom queries
export async function getTopPages(startDate?: string, endDate?: string, limit = 20) {
return runReport({
startDate, endDate, limit,
dimensions: ['pagePath', 'pageTitle'],
metrics: ['sessions', 'screenPageViews', 'bounceRate', 'averageSessionDuration', 'conversions'],
orderBy: { field: 'sessions', type: 'metric', desc: true },
})
}
export async function getChannels(startDate?: string, endDate?: string) {
return runReport({
startDate, endDate,
dimensions: ['sessionDefaultChannelGroup'],
metrics: ['sessions', 'newUsers', 'bounceRate', 'conversions', 'totalRevenue'],
orderBy: { field: 'sessions', type: 'metric', desc: true },
})
}
export async function getDevices(startDate?: string, endDate?: string) {
return runReport({
startDate, endDate,
dimensions: ['deviceCategory'],
metrics: ['sessions', 'bounceRate', 'averageSessionDuration', 'conversions'],
orderBy: { field: 'sessions', type: 'metric', desc: true },
})
}
export async function getDailyTrend(startDate?: string, endDate?: string) {
return runReport({
startDate, endDate,
dimensions: ['date'],
metrics: ['sessions', 'screenPageViews', 'newUsers', 'conversions'],
orderBy: { field: 'date', type: 'dimension', desc: false },
limit: 365,
})
}
export async function getEvents(startDate?: string, endDate?: string, limit = 30) {
return runReport({
startDate, endDate, limit,
dimensions: ['eventName'],
metrics: ['eventCount', 'conversions'],
orderBy: { field: 'eventCount', type: 'metric', desc: true },
})
}
export async function getDashboardSnapshot(startDate = '28daysAgo', endDate = 'today') {
const [pages, channels, devices, trend, events] = await Promise.all([
getTopPages(startDate, endDate),
getChannels(startDate, endDate),
getDevices(startDate, endDate),
getDailyTrend(startDate, endDate),
getEvents(startDate, endDate),
])
return { pages, channels, devices, trend, events }
}
17. GA4 Admin API — Configure Conversions
Conversions don't configure themselves in GA4. By default, only purchase is marked as a conversion. You need to explicitly mark any event you want to track as a goal.
Common events to mark as conversions:
// Standard GA4 events (built-in):
'purchase' // Completed transaction
'begin_checkout' // Started checkout flow
// Events you likely fire from your frontend:
'generate_lead' // Lead form submitted
'contact' // Contact form / WhatsApp tap / phone click
'sign_up' // Account or newsletter registration
'book_call' // Calendar booking
'download' // PDF or asset download
Script to configure all at once — run this once per site:
import * as dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
import { google } from 'googleapis'
async function main() {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/analytics.edit'], // 'edit' scope required
})
const admin = google.analyticsadmin({ version: 'v1alpha', auth })
const parent = process.env.GA4_PROPERTY_ID! // e.g. 'properties/000000000'
// First, list existing conversions to avoid duplicates
const existing = await admin.properties.conversionEvents.list({ parent })
const existingNames = new Set(existing.data.conversionEvents?.map(e => e.eventName) ?? [])
console.log('Already set as conversions:', [...existingNames].join(', '))
// Events to mark as conversions — customize for your site
const toAdd = ['contact', 'generate_lead', 'begin_checkout', 'sign_up']
.filter(name => !existingNames.has(name)) // skip if already a conversion
for (const eventName of toAdd) {
const res = await admin.properties.conversionEvents.create({
parent,
requestBody: { eventName, countingMethod: 'ONCE_PER_EVENT' },
})
console.log('✅ Created conversion:', res.data.eventName)
}
if (toAdd.length === 0) console.log('Nothing to add — all events already set as conversions')
}
main().catch(e => console.error('Error:', e.message))
Run it:
npx tsx scripts/setup-conversions.ts
These changes take effect immediately in GA4 reporting. Historical data is not retroactively converted, but all future events of these types will count as conversions.
18. Bing Webmaster Tools API
lib/bing-webmaster.ts
const BASE = 'https://ssl.bing.com/webmaster/api.svc/json'
// Read env vars lazily
const apiKey = () => process.env.BING_WEBMASTER_API_KEY!
const siteUrl = () => process.env.BING_SITE_URL!
async function get<T>(method: string, params: Record<string, string> = {}): Promise<T> {
const qs = new URLSearchParams({ apikey: apiKey(), ...params }).toString()
const res = await fetch(`${BASE}/${method}?${qs}`, {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
})
const json = await res.json()
if (json.ErrorCode) throw new Error(`Bing API ${json.ErrorCode}: ${json.Message}`)
return json.d as T
}
async function post<T>(method: string, body: object): Promise<T> {
const res = await fetch(`${BASE}/${method}?apikey=${apiKey()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body),
})
const json = await res.json()
if (json.ErrorCode) throw new Error(`Bing API ${json.ErrorCode}: ${json.Message}`)
return json.d as T
}
// Bing uses /Date(milliseconds-offset)/ format — convert to ISO
function parseDate(d: string): string {
const m = d.match(/\/Date\((\d+)/)
return m ? new Date(parseInt(m[1])).toISOString().slice(0, 10) : d
}
// List all verified sites on this Bing account
// ⚠️ Method is 'GetUserSites', NOT 'GetSites' — common mistake
export async function getSites() {
return get<any[]>('GetUserSites')
}
// Top search queries — clicks, impressions, average position
export async function getQueryStats(site = siteUrl()) {
const rows = await get<any[]>('GetQueryStats', { siteUrl: site })
return rows.map(r => ({ ...r, Date: parseDate(r.Date), __type: undefined }))
}
// Top pages by traffic
export async function getPageStats(site = siteUrl()) {
const rows = await get<any[]>('GetPageStats', { siteUrl: site })
return rows.map(r => ({ ...r, Date: parseDate(r.Date), __type: undefined }))
}
// Daily crawl health: pages crawled, indexed count, 2xx/4xx/5xx breakdown
export async function getCrawlStats(site = siteUrl()) {
const rows = await get<any[]>('GetCrawlStats', { siteUrl: site })
return rows.map(r => ({ ...r, Date: parseDate(r.Date), __type: undefined }))
}
// Check remaining URL submission quota
export async function getUrlQuota(site = siteUrl()) {
return get<{ DailyQuota: number; MonthlyQuota: number }>('GetUrlSubmissionQuota', { siteUrl: site })
}
// Submit multiple URLs — up to 10,000/day (vs Google's 200)
export async function submitUrls(urls: string[], site = siteUrl()) {
const quota = await getUrlQuota(site)
const toSubmit = urls.slice(0, Math.min(urls.length, quota.DailyQuota))
await post('SubmitUrlBatch', { siteUrl: site, urlList: toSubmit })
return { submitted: toSubmit.length, quota }
}
// Full snapshot — all data in one call
export async function getBingSnapshot(site = siteUrl()) {
const [queries, pages, crawl, quota] = await Promise.all([
getQueryStats(site), getPageStats(site), getCrawlStats(site), getUrlQuota(site),
])
return { queries, pages, crawl, quota, generatedAt: new Date().toISOString() }
}
Bing vs Google URL submission comparison:
| Google Indexing API | Bing URL Submission | |
|---|---|---|
| Daily limit | 200 URLs | 10,000 URLs |
| Monthly limit | ~6,000 | 20,000 URLs |
| Batch support | No (1 per request) | Yes (all in one POST) |
| Response | Per-URL result | Single success/fail |
19. SEO Analysis Engine
The real value comes from combining GSC + GA4 to automatically surface what needs attention. Build this as lib/seo-analysis.ts.
Core opportunity categories:
| Type | Criteria | Action |
|---|---|---|
content-gap |
100+ impressions, ≤5 clicks, position > 15 | Create a dedicated page targeting this query |
quick-win |
Position 4–15, 20+ impressions | Improve content depth and internal linking |
featured-snippet |
Position 1–5, CTR < 5%, 30+ impressions | Add a 40–60 word direct answer box above the fold |
ctr-fix |
Position ≤10, CTR < 3%, 50+ impressions | Rewrite the page title and meta description |
Key function — get all opportunities:
import { getSearchAnalytics } from './search-console'
function toDate(relative?: string): string {
if (!relative || relative === 'today') return new Date().toISOString().slice(0, 10)
const m = relative.match(/^(\d+)daysAgo$/)
if (m) {
const d = new Date()
d.setDate(d.getDate() - parseInt(m[1]))
return d.toISOString().slice(0, 10)
}
return relative
}
export async function getKeywordOpportunities(startDate = '90daysAgo', endDate = 'today') {
const rows = await getSearchAnalytics({
startDate: toDate(startDate),
endDate: toDate(endDate),
dimensions: ['query', 'page'],
rowLimit: 1000,
})
const BRAND = 'yourdomain' // Change to your brand name — filters out branded queries
const results = []
for (const row of rows) {
const query = (row.keys?.[0] ?? '').toLowerCase()
const page = row.keys?.[1] ?? ''
const impressions = row.impressions ?? 0
const clicks = row.clicks ?? 0
const ctr = row.ctr ?? 0
const position = row.position ?? 0
if (query.includes(BRAND)) continue // skip branded queries
if (impressions >= 100 && clicks <= 5 && position > 15)
results.push({ query, page, impressions, clicks, ctr, position, type: 'content-gap' })
else if (position >= 4 && position <= 15 && impressions >= 20)
results.push({ query, page, impressions, clicks, ctr, position, type: 'quick-win' })
else if (position <= 5 && ctr < 0.05 && impressions >= 30)
results.push({ query, page, impressions, clicks, ctr, position, type: 'featured-snippet' })
else if (position <= 10 && ctr < 0.03 && impressions >= 50)
results.push({ query, page, impressions, clicks, ctr, position, type: 'ctr-fix' })
}
// Priority order: content-gap > quick-win > featured-snippet > ctr-fix
const order = { 'content-gap': 0, 'quick-win': 1, 'featured-snippet': 2, 'ctr-fix': 3 }
return results.sort((a, b) => order[a.type] - order[b.type]).slice(0, 50)
}
20. API Routes (Next.js App Router)
Create these files in your project. Each one exposes a clean HTTP endpoint.
app/api/search-console/analytics/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getSearchAnalytics } from '@/lib/search-console'
export async function GET(req: NextRequest) {
try {
const p = req.nextUrl.searchParams
const rows = await getSearchAnalytics({
startDate: p.get('startDate') ?? new Date(Date.now() - 28*86400000).toISOString().slice(0,10),
endDate: p.get('endDate') ?? new Date().toISOString().slice(0,10),
dimensions: (p.get('dimensions') ?? 'query').split(',') as any,
rowLimit: parseInt(p.get('rowLimit') ?? '1000'),
})
return NextResponse.json({ rows })
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
app/api/analytics/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getDashboardSnapshot, getTopPages, getChannels, getDevices, getDailyTrend, getEvents } from '@/lib/ga4'
export async function GET(req: NextRequest) {
try {
const p = req.nextUrl.searchParams
const report = p.get('report') ?? 'snapshot'
const startDate = p.get('startDate') ?? undefined
const endDate = p.get('endDate') ?? undefined
switch (report) {
case 'snapshot': return NextResponse.json(await getDashboardSnapshot(startDate, endDate))
case 'pages': return NextResponse.json(await getTopPages(startDate, endDate))
case 'channels': return NextResponse.json(await getChannels(startDate, endDate))
case 'devices': return NextResponse.json(await getDevices(startDate, endDate))
case 'trend': return NextResponse.json(await getDailyTrend(startDate, endDate))
case 'events': return NextResponse.json(await getEvents(startDate, endDate))
default: return NextResponse.json({ error: 'Unknown report' }, { status: 400 })
}
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
app/api/pagespeed/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { runPageSpeedBoth, runPageSpeed } from '@/lib/pagespeed'
export async function GET(req: NextRequest) {
try {
const url = req.nextUrl.searchParams.get('url')
const strategy = req.nextUrl.searchParams.get('strategy') ?? 'both'
if (!url) return NextResponse.json({ error: 'url required' }, { status: 400 })
const result = strategy === 'both' ? await runPageSpeedBoth(url) : await runPageSpeed(url, strategy as any)
return NextResponse.json(result)
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
app/api/bing/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getBingSnapshot, getQueryStats, getPageStats, getCrawlStats, getUrlQuota, submitUrls } from '@/lib/bing-webmaster'
export async function GET(req: NextRequest) {
try {
const report = req.nextUrl.searchParams.get('report') ?? 'snapshot'
switch (report) {
case 'snapshot': return NextResponse.json(await getBingSnapshot())
case 'queries': return NextResponse.json(await getQueryStats())
case 'pages': return NextResponse.json(await getPageStats())
case 'crawl': return NextResponse.json(await getCrawlStats())
case 'quota': return NextResponse.json(await getUrlQuota())
default: return NextResponse.json({ error: 'Unknown report' }, { status: 400 })
}
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const { urls } = await req.json()
if (!urls?.length) return NextResponse.json({ error: 'urls required' }, { status: 400 })
return NextResponse.json(await submitUrls(urls))
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
app/api/indexnow/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { submitToIndexNow } from '@/lib/indexnow'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const urls: string[] = body.urls ?? (body.url ? [body.url] : [])
if (!urls.length) return NextResponse.json({ error: 'url or urls required' }, { status: 400 })
return NextResponse.json(await submitToIndexNow(urls))
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
21. Scripts — Automation & Maintenance
Add these scripts to package.json:
{
"scripts": {
"deploy:notify": "tsx scripts/post-deploy.ts",
"track:ranks": "tsx scripts/rank-tracker.ts",
"check:coverage": "tsx scripts/check-index-coverage.ts"
}
}
scripts/post-deploy.ts — Run after every deployment
Notifies Google, Bing, and IndexNow of all updated URLs. Run this every time you deploy:
npm run deploy:notify
Design:
- Google Indexing API: submits your top 200 priority pages (homepage, service pages, guides, key content) — highest-value pages first since the 200/day limit will cut off lower-priority pages
- IndexNow: submits all pages in one batch (no limit)
- Bing URL Submission: submits all priority pages (10,000/day limit)
scripts/rank-tracker.ts — Run weekly
npm run track:ranks
Pulls the last 7 days of GSC data and saves a snapshot to data/rank-history.json. On each run, compares to the previous snapshot and flags any query that moved more than 3 positions. After a few weeks you have a trend history.
scripts/check-index-coverage.ts — Run after deploys
npm run check:coverage
Uses the GSC URL Inspection API to check whether your key pages are indexed, whether robots.txt is blocking anything, and what the rich results verdict is. Useful after deploying new pages or structural changes.
22. On-Page SEO Best Practices
These are specific techniques that made a measurable difference — apply them to every site.
Title tags
- Lead with the primary keyword: "Cenote Diving Riviera Maya —" not "Explore the Depths —"
- Use "at" not "near" for transactional location pages: "Scuba Diving at [Hotel Name]" outperforms "Scuba Diving Near [Hotel Name]" because the searcher's query says "at"
- Add a year for content where freshness matters: "Best Cenotes (2026)"
- Keep under 60 characters — longer titles get cut off in SERPs
- Test both variants — GSC shows you CTR per page; if it's low, the title is the first thing to change
Meta descriptions
- Start with a direct answer for informational pages: "A cenote is a natural sinkhole..."
- Start with "Yes —" for transactional service pages: "Yes — we offer private diving with free pickup from [Hotel]" — directly answers the implied question "is there diving here?"
- Target 150–160 characters
- Include the primary keyword in the first 20 words
metadataBase in Next.js
// app/layout.tsx — must match your canonical domain exactly
export const metadata: Metadata = {
metadataBase: new URL('https://www.yourdomain.com'),
}
Every relative URL in your metadata resolves against this base. If your infrastructure redirects to www, this must say www. If it redirects to non-www, this must say non-www.
Structured data (JSON-LD)
Always add these to every site:
Organization+WebSite+SearchActionon the homepageBreadcrumbListon every non-homepage pageFAQPageon any page with visible FAQ content — even just 2 questionsLocalBusinessif you serve a physical area
FAQPage is the highest-ROI schema — any page with questions that doesn't have FAQPage schema is leaving rich results on the table. The questions in schema must match the visible questions on the page exactly. Target actual search queries as your FAQ questions (e.g. "Is there scuba diving at [Hotel Name]?" for hotel pages) and your CTR for long-tail question queries will improve significantly.
Featured snippet optimization
When GSC shows a query at position 1–3 with < 5% CTR and 30+ impressions, a featured snippet is eating your traffic. Fix:
<!-- Add near the top of the page, after the hero/intro -->
<section>
<h2>What Is a Cenote?</h2>
<p>
A cenote (seh-NO-teh) is a natural sinkhole formed when limestone bedrock collapses,
exposing crystal-clear groundwater beneath. Found almost exclusively in Mexico's
Yucatán Peninsula, cenotes were sacred to the ancient Maya and now offer some of the
world's best cave diving and snorkeling.
</p>
</section>
Rules: 40–60 words, direct answer, uses the exact query phrasing as the heading.
Heading hierarchy
HTML headings must be sequential: H1 → H2 → H3 → H4. Never skip a level. This causes a PageSpeed Insights accessibility flag and hurts how search engines parse page structure.
Fix: Change the tag, not the CSS class. <h3 className="text-4xl"> → <h2 className="text-4xl">. Same visual appearance, correct semantics.
23. Canonical / www Setup
Canonical mismatch is one of the most common and damaging silent SEO bugs. It splits your impressions and link equity across two "versions" of the same page.
Step 1: Identify your canonical domain
Check your hosting platform (Vercel, Cloudflare, etc.) for which domain redirects to which. There is always one "winner":
yourdomain.com→ 308 →www.yourdomain.com→ canonical is wwwwww.yourdomain.com→ 308 →yourdomain.com→ canonical is non-www
Step 2: Update metadataBase
// Canonical is www:
metadataBase: new URL('https://www.yourdomain.com')
// Canonical is non-www:
metadataBase: new URL('https://yourdomain.com')
Step 3: Find all hardcoded URLs in your codebase
grep -r "https://yourdomain.com" . --include="*.ts" --include="*.tsx" --include="*.json" \
--exclude-dir=node_modules --exclude-dir=.next
Step 4: Replace all at once
# Save as fix-canonicals.py and run: python3 fix-canonicals.py
import os
OLD = 'https://yourdomain.com' # The non-canonical version
NEW = 'https://www.yourdomain.com' # The canonical version
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in ['node_modules', '.next', '.git']]
for f in files:
if f.endswith(('.ts', '.tsx', '.json', '.js')):
path = os.path.join(root, f)
content = open(path).read()
updated = content.replace(OLD, NEW)
if updated != content:
open(path, 'w').write(updated)
print('Updated:', path)
Step 5: Verify
After deploying, check a few pages with the URL inspection tool in GSC. The "Google-selected canonical" should match your intended canonical. If it doesn't after a week, something is still misconfigured.
24. What Data Each API Gives You
| API | Key data points |
|---|---|
| Google Search Console | Search queries with clicks/impressions/CTR/position. Which pages rank for which queries. Sitemaps. URL index + rich results status. |
| Google Indexing API | Submit URLs to Google's crawl queue. Check last submission time. Signal deleted pages. |
| GA4 Data API | Sessions, users, bounce rate, session duration by page/channel/device/country. Events and conversions. Revenue. |
| GA4 Admin API | Mark events as conversions. Configure data streams. Set custom dimensions. |
| PageSpeed Insights | Lighthouse performance/SEO/accessibility/best-practices scores. Core Web Vitals (LCP, CLS, INP). Top 10 failed audits with estimated savings. |
| IndexNow | Submit-only. Notifies Bing, Yandex, Seznam, Naver simultaneously. No data returned. |
| Bing Webmaster API | Bing-specific search queries |
Truncated - read the full file at https://github.com/isaachorowitz/multi-agent-setup/blob/007989d9688a4dc78f4285e675160c1b79ca5c08/growth-skills/seo/seo-api-integrations/SKILL.md.