Skip to content
Skillv1.0.0

seo-api-integrations

When the user wants to connect SEO APIs to a Next.js site. Also use when the user mentions "Search Console", "GA4", "IndexNow", "Bing Webmaster", "PageSpeed Insights", "indexing API", or "service acco

by isaachorowitz(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from isaachorowitz/multi-agent-setup (growth-skills/seo/seo-api-integrations/SKILL.md). Install upstream with npx 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

  1. Prerequisites — What You Need Before Starting
  2. Install All Dependencies
  3. Google Cloud Service Account Setup
  4. Enable Required Google APIs
  5. Add Service Account to Search Console
  6. Add Service Account to GA4 & Find Your Property ID
  7. Set Up Bing Webmaster Tools
  8. Set Up IndexNow (Bing, Yandex & Others)
  9. Get a PageSpeed Insights API Key
  10. Environment Variables — Complete Template
  11. Verify Everything Works
  12. Google Search Console API (lib/search-console.ts)
  13. Google Indexing API (lib/indexing.ts)
  14. IndexNow (lib/indexnow.ts)
  15. PageSpeed Insights API (lib/pagespeed.ts)
  16. Google Analytics Data API — GA4 (lib/ga4.ts)
  17. GA4 Admin API — Configure Conversions
  18. Bing Webmaster Tools API (lib/bing-webmaster.ts)
  19. SEO Analysis Engine (lib/seo-analysis.ts)
  20. API Routes (Next.js App Router)
  21. Scripts — Automation & Maintenance
  22. On-Page SEO Best Practices
  23. Canonical / www Setup
  24. What Data Each API Gives You
  25. SEO Analysis Patterns — Acting on the Data
  26. 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

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.com vs yourdomain.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

  1. Go to console.cloud.google.com

  2. 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
  3. 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
  4. 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
  5. 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_email and private_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

  1. Go to console.cloud.google.com/apis/library
  2. Make sure your project is selected in the top dropdown
  3. Search for the API name
  4. 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

  1. Go to search.google.com/search-console
  2. Select your property from the left sidebar
  3. Click Settings (gear icon in the left sidebar, near the bottom)
  4. Click Users and permissions
  5. Click Add user (top right)
  6. In the email field, paste your service account's client_email
    • It looks like: seo-service-account@your-project.iam.gserviceaccount.com
  7. 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
  8. 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

  1. Go to analytics.google.com
  2. Click the Admin gear icon (bottom left)
  3. In the Property column (middle column), click Property Access Management
  4. Click the + button (top right) → Add users
  5. Enter the service account client_email
  6. 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
  7. Uncheck "Notify new users by email" (the service account doesn't have an inbox)
  8. Click Add

Find your GA4 Property ID (manual method)

  1. In GA4 Admin, click Property Settings (in the Property column)
  2. At the top you'll see Property ID — a plain number like 000000000
  3. 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)

  1. Go to bing.com/webmasters and sign in with a Microsoft account
  2. Click Add a site → enter your site URL
  3. Choose a verification method:
    • XML file (easiest): download the file, place it in your /public folder, 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)

Get your Bing API key

  1. In Bing Webmaster Tools, click Settings (gear icon, top right)
  2. Click API Access
  3. Click Generate API Key
  4. 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

  1. Go to console.cloud.google.com/apis/credentials
  2. Make sure your project is selected
  3. Click + Create Credentials → API Key
  4. A key is generated immediately — copy it
  5. 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, \n kept as-is
  • GOOGLE_SEARCH_CONSOLE_SITE_URL — uses sc-domain: prefix for domain properties
  • GA4_PROPERTY_ID — starts with properties/ followed by the numeric ID
  • GOOGLE_PSI_API_KEY — starts with AIzaSy
  • INDEXNOW_KEY — 32-char hex string, matching file created in public/
  • 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 error in the response = success, even if notifyTime is absent
  • Officially documented only for JobPosting and BroadcastEvent schema pages, but widely used and works for all page types in practice
  • Use URL_DELETED when 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 at keyLocation URL — check public/[key].txt is deployed
  • 422 — URLs don't match the host — all submitted URLs must be on the same domain as HOST
  • 429 — 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 + SearchAction on the homepage
  • BreadcrumbList on every non-homepage page
  • FAQPage on any page with visible FAQ content — even just 2 questions
  • LocalBusiness if 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 www
  • www.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.

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/isaachorowitz-multi-agent-setup-seo-api-integrations/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

isaachorowitz-multi-agent-setup-seo-api-integrations.ocm.jsonjson
{
  "ocm": "1",
  "id": "isaachorowitz-multi-agent-setup-seo-api-integrations",
  "kind": "skill",
  "name": "seo-api-integrations",
  "description": "When the user wants to connect SEO APIs to a Next.js site. Also use when the user mentions \"Search Console\", \"GA4\", \"IndexNow\", \"Bing Webmaster\", \"PageSpeed Insights\", \"indexing API\", or \"service account\". Step-by-step setup and code for every major SEO data API.",
  "publisher": "isaachorowitz",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding",
      "marketing"
    ],
    "tags": [
      "skill-md",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "When the user wants to connect SEO APIs to a Next.js site. Also use when the user mentions \"Search Console\", \"GA4\", \"IndexNow\", \"Bing Webmaster\", \"PageSpeed Insights\", \"indexing API\", or \"service account\". Step-by-step setup and code for every major SEO data API."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/isaachorowitz/multi-agent-setup",
      "path": "growth-skills/seo/seo-api-integrations/SKILL.md",
      "ref": "007989d9688a4dc78f4285e675160c1b79ca5c08",
      "url": "https://github.com/isaachorowitz/multi-agent-setup/blob/007989d9688a4dc78f4285e675160c1b79ca5c08/growth-skills/seo/seo-api-integrations/SKILL.md",
      "key": "isaachorowitz/multi-agent-setup/growth-skills/seo/seo-api-integrations/SKILL.md"
    }
  },
  "instructions": "# Complete SEO API Setup Guide — Search Console, Analytics, Bing & More\n\nA 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.\n\n---\n\n## Table of Contents\n\n1. [Prerequisites — What You Need Before Starting](#1-prerequisites--what-you-need-before-starting)\n2. [Install All Dependencies](#2-install-all-dependencies)\n3. [Google Cloud Service Account Setup](#3-google-cloud-service",
  "cost": {
    "context_tokens": 21683
  }
}

Fetch it by URL: GET /api/v1/registry/isaachorowitz-multi-agent-setup-seo-api-integrations/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.