← Back to blog

Published

Build a Rank and AI Overview Citation Tracker in TypeScript

Ranking first on Google used to be the whole game. Now the first thing many searchers see is an AI Overview, and on some queries they never scroll past it. A page can hold the top organic spot and still be missing from the AI answer above it, or rank on page two and be the answer's main source.

So a useful rank tracker in 2026 answers two questions per keyword: where do we rank, and does the AI answer cite us? This guide builds one in TypeScript using the official PrismCrawl Node.js SDK. It checks Google, Google AI Mode, and Bing, saves every result, and on each run prints only what changed.

What the tracker records

For every keyword on every engine, the script saves one snapshot:

  • rank: your domain's organic position, or null if it isn't on the page
  • aiAnswer: whether an AI answer appeared (a Google AI Overview, a Google AI Mode answer, or Bing's AI overview)
  • aiCited: whether that answer links to your domain
  • aiSources: every domain the answer linked to, so you can see who gets cited instead of you
  • requestId: the PrismCrawl request ID, so you can open the exact result later in your dashboard's request history

Snapshots go into a JSON Lines file. Each run compares itself with the previous one and prints changes like rank 4 → 2 or dropped from AI answer.

Setup

The SDK requires Node.js 18 or later and has no runtime dependencies.

npm install prismcrawl
npm install --save-dev tsx
export PRISMCRAWL_API_KEY="your-api-key"

Create a free PrismCrawl account to get an API key; new accounts get 100 free credits. The client reads PRISMCRAWL_API_KEY from the environment. Keep the key on the server, and never ship it in browser code.

Why the SDK helps here

You could make the same requests with fetch, as the JavaScript guide shows. The SDK earns its place in a tracker for three reasons:

  1. Typed responses. Google and Bing responses share a shape: results for organic listings and serp_features for everything else, including AI answers. The SDK types both, so your editor shows feature.links and result.rank as you type, and a typo fails at compile time instead of silently recording undefined for a month.
  2. Retries built in. A tracker sends bursts of requests. The client retries rate limits, server errors, and connection failures with exponential backoff, so one flaky request doesn't leave a gap in your history.
  3. Errors with request IDs. Failures throw typed errors like RateLimitError and InsufficientCreditsError, each carrying the request ID you'd give support.

Checking one keyword

Here is the core of the tracker: run one search and turn the response into a snapshot.

import PrismCrawl from "prismcrawl";

const DOMAIN = "example.com";
const client = new PrismCrawl({ maxRetries: 4 });

function hostOf(url: string | null | undefined): string | null {
  if (!url) return null;
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return null;
  }
}

function isOurs(url: string | null | undefined): boolean {
  const host = hostOf(url);
  return host !== null && (host === DOMAIN || host.endsWith(`.${DOMAIN}`));
}

const response = await client.google.search({ query: "serp api", gl: "us", hl: "en" });

if (response.data.format === "json") {
  const { results, serp_features } = response.data.content;

  const organic = results.find((result) => result.type === "organic" && isOurs(result.url));
  const aiAnswer = serp_features.find((feature) => feature.type === "ai_summary");
  const aiCited = aiAnswer?.links.some((link) => isOurs(link.url)) ?? false;

  console.log({ rank: organic?.rank ?? null, aiAnswer: Boolean(aiAnswer), aiCited });
}

The response.data.format === "json" check matters to TypeScript. A response can also be raw HTML if you request it with html: true, and checking the format narrows the type so content.results is available.

AI answers come back in serp_features with type: "ai_summary", in the same format for Google and Bing. The AI Overview API page shows the request and how to read the response. The cited sources are in links, and some answers also carry links in items, so the full script checks both.

Adding Google AI Mode and Bing

Google AI Mode is Google's chat-style search. It's a separate surface from AI Overviews, and its answers can cite different sources. To request it, pass udm: 50. Bing uses different parameter names (cc instead of gl), and the SDK's types keep you from mixing them up:

function search(engine: Engine, query: string) {
  switch (engine) {
    case "google":
      return client.google.search({ query, gl: "us", hl: "en", device: "desktop" });
    case "google-ai-mode":
      return client.google.search({ query, gl: "us", hl: "en", udm: 50 });
    case "bing":
      return client.bing.search({ query, cc: "us", device: "desktop" });
  }
}

AI answers are included at no extra charge: each of these requests uses one credit, whether or not an AI answer appears.

The full tracker

This version adds a keyword list, a small concurrency limit, snapshot storage, and change detection. Save it as tracker.ts:

import { appendFile, readFile } from "node:fs/promises";

import PrismCrawl, { APIStatusError } from "prismcrawl";

const DOMAIN = "example.com";
const KEYWORDS = ["serp api", "google search api", "rank tracking api"];
const ENGINES = ["google", "google-ai-mode", "bing"] as const;
const SNAPSHOT_FILE = "snapshots.jsonl";
const CONCURRENCY = 4;

type Engine = (typeof ENGINES)[number];

type Snapshot = {
  checkedAt: string;
  keyword: string;
  engine: Engine;
  rank: number | null; // organic rank of DOMAIN, or null when it isn't on the page
  url: string | null;
  aiAnswer: boolean; // an AI Overview, AI Mode answer, or Bing AI answer appeared
  aiCited: boolean; // DOMAIN is one of the AI answer's sources
  aiSources: string[]; // every domain the AI answer linked to
  requestId: string;
};

const client = new PrismCrawl({ maxRetries: 4 });

function hostOf(url: string | null | undefined): string | null {
  if (!url) return null;
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return null;
  }
}

function isOurs(url: string | null | undefined): boolean {
  const host = hostOf(url);
  return host !== null && (host === DOMAIN || host.endsWith(`.${DOMAIN}`));
}

function search(engine: Engine, query: string) {
  switch (engine) {
    case "google":
      return client.google.search({ query, gl: "us", hl: "en", device: "desktop" });
    case "google-ai-mode":
      return client.google.search({ query, gl: "us", hl: "en", udm: 50 });
    case "bing":
      return client.bing.search({ query, cc: "us", device: "desktop" });
  }
}

async function check(engine: Engine, keyword: string): Promise<Snapshot> {
  const response = await search(engine, keyword);
  const { data } = response;
  if (data.format !== "json") throw new Error("Expected a JSON response");

  const { results, serp_features } = data.content;
  const organic = results.find((result) => result.type === "organic" && isOurs(result.url));

  const aiFeatures = serp_features.filter((feature) => feature.type === "ai_summary");
  const aiUrls = aiFeatures.flatMap((feature) => [
    ...feature.links.map((link) => link.url),
    ...feature.items.map((item) => item.link),
  ]);
  const aiSources = [...new Set(aiUrls.map(hostOf).filter((host) => host !== null))];

  return {
    checkedAt: new Date().toISOString(),
    keyword,
    engine,
    rank: organic?.rank ?? null,
    url: organic?.url ?? null,
    aiAnswer: aiFeatures.length > 0,
    aiCited: aiUrls.some(isOurs),
    aiSources,
    requestId: response.request_id,
  };
}

// Runs fn over items with at most `limit` requests in flight.
async function mapWithLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<R>,
): Promise<PromiseSettledResult<R>[]> {
  const settled: PromiseSettledResult<R>[] = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const index = next++;
      try {
        settled[index] = { status: "fulfilled", value: await fn(items[index]) };
      } catch (reason) {
        settled[index] = { status: "rejected", reason };
      }
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return settled;
}

async function loadPrevious(): Promise<Map<string, Snapshot>> {
  const latest = new Map<string, Snapshot>();
  const text = await readFile(SNAPSHOT_FILE, "utf8").catch(() => "");
  for (const line of text.split("\n")) {
    if (!line.trim()) continue;
    const snapshot = JSON.parse(line) as Snapshot;
    latest.set(`${snapshot.engine}|${snapshot.keyword}`, snapshot);
  }
  return latest;
}

function describeChange(before: Snapshot | undefined, after: Snapshot): string | null {
  if (!before) return "first check";
  const changes: string[] = [];
  if (before.rank !== after.rank) {
    changes.push(`rank ${before.rank ?? "–"} → ${after.rank ?? "–"}`);
  }
  if (before.aiCited !== after.aiCited) {
    changes.push(after.aiCited ? "now cited in AI answer" : "dropped from AI answer");
  } else if (before.aiAnswer !== after.aiAnswer) {
    changes.push(after.aiAnswer ? "AI answer appeared" : "AI answer disappeared");
  }
  return changes.length > 0 ? changes.join(", ") : null;
}

const previous = await loadPrevious();
const jobs = KEYWORDS.flatMap((keyword) => ENGINES.map((engine) => ({ keyword, engine })));
const settled = await mapWithLimit(jobs, CONCURRENCY, ({ engine, keyword }) =>
  check(engine, keyword),
);

const snapshots: Snapshot[] = [];
settled.forEach((outcome, index) => {
  const { engine, keyword } = jobs[index];
  if (outcome.status === "rejected") {
    const error = outcome.reason;
    const detail =
      error instanceof APIStatusError ? `${error.status} ${error.code} (${error.requestId})` : error;
    console.error(`✗ ${engine} "${keyword}":`, detail);
    return;
  }
  const snapshot = outcome.value;
  snapshots.push(snapshot);
  const change = describeChange(previous.get(`${engine}|${keyword}`), snapshot);
  if (change) console.log(`${engine.padEnd(15)} ${keyword.padEnd(24)} ${change}`);
});

await appendFile(SNAPSHOT_FILE, snapshots.map((s) => JSON.stringify(s) + "\n").join(""));
console.log(`Saved ${snapshots.length}/${jobs.length} checks to ${SNAPSHOT_FILE}`);

Run it:

npx tsx tracker.ts

The first run prints first check for every row. Later runs print only rows that moved:

google          serp api                 rank 4 → 2
google-ai-mode  google search api        now cited in AI answer
bing            rank tracking api        dropped from AI answer
Saved 9/9 checks to snapshots.jsonl

A few notes on the design:

  • The concurrency limit is deliberate. Firing every request at once with Promise.all works for three keywords, but with hundreds you'd hit your rate limit and lean on retries. Four in flight keeps a steady pace. Raise it if your plan allows.
  • One failed check doesn't stop the run. Each job settles independently, failures are logged with their request ID, and the rest are still saved.
  • Expect rank to be null more often for AI Mode. An AI Mode page is built around the answer, so it may show few or no organic listings. For that engine, the citation fields are the ones to watch.
  • aiSources is the most useful field over time. Knowing that you aren't cited is only half the story. Seeing which domains are cited, week after week, shows you whose content the AI answer prefers for that topic.

Run it on a schedule

Rankings and AI answers change daily, so run the tracker at a fixed time each day. On a server, a crontab entry is enough:

0 7 * * * cd /opt/rank-tracker && npx tsx tracker.ts >> tracker.log 2>&1

Keep the location and device parameters the same on every run. Results from gl: "us" on desktop and results from a mobile searcher in London are different result pages, and comparing them makes changes appear that never happened. The localization guide explains how gl, hl, and location work together.

What it costs

Each check uses one credit: one keyword on one engine. Failed requests are free, and AI answers don't cost extra. Tracking 50 keywords on all three engines once a day uses 150 credits a day, about 4,500 a month. Drop AI Mode or Bing for keywords where you only care about Google to cut that by a third or two-thirds. Credit packages start at $5 with no subscription; see pricing for current rates.

Reading the results honestly

AI answers are less stable than organic rankings. The same query can show an AI Overview in the morning and none in the afternoon, and the cited sources can change between runs. Treat a single run as a sample, not a verdict:

  • Look at citation rates over a week or a month ("cited in 5 of the last 7 checks") rather than reacting to one change.
  • If a keyword matters a lot, check it more than once per run and record how often you're cited.
  • Compare engines separately. Being cited on Bing but not Google is a real and useful signal, not noise to average away.

Our guide to tracking brand visibility in AI search goes deeper on sampling, prompt selection, and which metrics hold up. For the organic side, building an SEO rank tracker covers location and device accuracy in more detail, and the rank tracking API page covers parameters and pricing at volume.

Frequently asked questions

How do I check if Google's AI Overview cites my website?

Run the query through a SERP API that parses AI Overviews, find the feature with type: "ai_summary" in serp_features, and check whether any of its links point to your domain. PrismCrawl returns AI Overviews and Bing's AI answers in the same serp_features format.

How do I get Google AI Mode results from an API?

Send a Google search with udm: 50. PrismCrawl returns the AI Mode answer as an ai_summary feature in serp_features, with its cited links, at no extra charge.

How many credits does a daily rank tracker use?

One credit per keyword per engine per run. Checking 50 keywords on Google, Google AI Mode, and Bing once a day uses 150 credits a day, or about 4,500 a month. Failed requests are free.

Is there a Python version?

Yes. The Python SDK has the same endpoints and response shapes, plus an async client for running checks concurrently. See how to scrape reviews with Python for an example of the async pattern.