AI Overviews have become the first thing many people read on Google, summarizing instructions, comparisons, and even simple calculations while citing sources at the top of the results page. In previous articles, we learned how to scrape Google AI Overviews with SerpApi, and specifically how to do this with Node JS.

In this post, we walk through the AI Overview data returned by SerpApi and show how to use it in a small, real-world example. We focus on the response structure—text blocks, highlights, and references—and explain when and why you may need to perform an additional request so you can render the overview cleanly and reliably in your UI.

SerpApi

SerpApi is a web scraping API that returns structured JSON from Google and other search engines. It handles the complex parts for you, including proxies, CAPTCHAs, headless browsing, and localization, so a single HTTP request yields clean, consistent results. In this post, we’ll use its Google Search and AI Overview endpoints to fetch and render the overview content.

Prerequisites

  • SerpApi API Key - If you don’t have an account yet, you can sign up for a free plan with 250 searches per month. If you already have an account, your API key is available in your dashboard.
  • Node.js 7.10.1 or newer (optional) - Used later in our real-world example.

AI Overview Flow

Before we dive into the data, it helps to understand how an AI Overview is obtained.

Initial Request

For many queries, the Google Search API returns an AI Overview directly in the initial response. This is the simplest path because you can parse and render the ai_overview object immediately with no extra calls.

Follow-up request (when a page_token is present)

For less common or more complex queries, Google may lazy-load the AI Overview while its model finishes constructing the response. In these cases, the initial Search API response includes a short-lived page_token. Use this token with SerpApi’s AI Overview API to fetch the full overview.

💡
Note: because this involves two API calls, it will count as two SerpApi searches.

Best Practices

  • Use no_cache: true to avoid stale results and expired tokens.
  • If a page_token is present, call the google_ai_overview engine immediately.
  • Process each query sequentially rather than in parallel to reduce the chance of token expiration.

For more information, check out my blog post on Fetching AI Overviews:

Fetching AI Overviews with Node.js
AI Overviews are quickly becoming a prominent feature in today’s SEO landscape, and with SerpApi, extracting this data is simple.

Anatomy of an AI Overview

At a glance, we'll be working with two top-level objects:

  • text_blocks: ordered content blocks to be shown in sequence (paragraphs, headings, lists).
  • references: a flat array of citations you can map to via numeric indices.

Here's a simple mental model for how AI Overviews are structured:

ai_overview
  ├─ text_blocks[]   ← blocks to render in order
  │   ├─ type        ← "paragraph" | "heading" | "list" | ...
  │   ├─ snippet     ← content text
  │   ├─ snippet_highlighted_words[] (optional)
  │   ├─ reference_indexes[] (optional)
  │   └─ list[]      ← when type === "list": array of { title, snippet, reference_indexes? }
  └─ references[]    ← [{ title, link, snippet, source, index }]

Common block types

This is not an exhaustive list, since Google can introduce new types, but these are the ones you will see most often.

  • Paragraph: plain text in snippet. May include snippet_highlighted_words for emphasis and reference_indexes for inline citations.
  • Heading: section titles that segment the overview (for example, “Key features and functionality”, “Additional considerations”).
  • List: list is an array of items. Each item has a title, a snippet, and optional reference_indexes. Typically in the form of unordered lists.

Highlights and citations

Some blocks include highlighted words for emphasis or references that link to sources.

  • snippet_highlighted_words: an array of terms or phrases you can style in the UI for quick scanning.
  • reference_indexesreferences: each number in reference_indexes maps to a record in references. Use these for superscripts or footnotes that link to the source list.

For example:

  • A paragraph with reference_indexes: [0, 2] should show two citations, which resolve to references[0] and references[2].
  • A list item can do the same. Treat each item’s reference_indexes independently, so you only show the citations that support that item.
💡
I recommend using switch statements on block.type, render snippet or list accordingly, then pass reference_indexes and the full references array to a small Citations helper.

This keeps the mapping logic in one place and makes it easy to add future block types without touching the rest of your UI.

Real World Example

Below is a simple Node.js + React setup that proxies requests from the frontend (to avoid CORS), performs the AI Overview follow-up when a page_token is present, and renders the common block types.

If you'd like to run or reference the example, you can find the repo here:

GitHub - NateSkiles/using_ai_overviews
Contribute to NateSkiles/using_ai_overviews development by creating an account on GitHub.

Server

Keep your API key server-side, avoid CORS, and conditionally call the AI Overview API only when a page_token is present. Below is the code I used to handle requests from my frontend and pass them along to SerpApi.

Some key points to note:

  • json_restrictor=ai_overview in the initial request, ensures that we only return the AI Overview for this example. This can decrease response times and make it easier to focus on relevant data in the response, but it can be removed if you require additional data from the search results.
  • no_cache=true reduces token-expiration headaches.
  • A page_token follow-up is a second SerpApi call.
// ./server/index.js
app.get("/api/search", async (req, res) => {
  try {
    const q = req.query.q || "";
    if (!q) return res.status(400).json({ error: "Missing q" });

    // 1) Initial search: ask only for the AI Overview
    const params = new URLSearchParams({
      engine: "google",
      q,
      api_key: process.env.SERPAPI_KEY,
      json_restrictor: "ai_overview",
      no_cache: "true",            // avoid stale results and expired tokens
    });

    const request = await fetch(`https://serpapi.com/search.json?${params}`);
    const data = await request.json();

    // 2) If Google did not include ai_overview at all, return what we got
    if (!data.ai_overview) {
      return res.json(data);
    }

    // 3) If a page_token is present, fetch the full AI Overview
    const token = data.ai_overview.page_token;
    if (token) {
      const aio_params = new URLSearchParams({
        engine: "google_ai_overview",
        page_token: token,
        api_key: process.env.SERPAPI_KEY,
        no_cache: "true",
      });
      const aio_request = await fetch(`https://serpapi.com/search.json?${aio_params}`);
      const aio_data = await aio_request.json();
      return res.json(aio_data);      // note: counts as a second SerpApi search
    }

    // 4) Otherwise, we already have the overview
    return res.json(data);
  } catch (e) {
    res.status(500).json({ error: String(e) });
  }
});
using_ai_overviews/server/index.js at main · NateSkiles/using_ai_overviews
Contribute to NateSkiles/using_ai_overviews development by creating an account on GitHub.

Client

When the form is submitted, we clear any previous state, ask our server for results, and stash the returned ai_overview in state. The server already handles the follow-up call when a page_token appears, so the client only needs to check whether an overview exists.

// ./client/src/App.jsx
async function onSearch(e) {
  e.preventDefault();
  setError(null);
  setOverview(null);
  setLoading(true);

  try {
    const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
    const data = await r.json();

    if (!r.ok) throw new Error(data?.error || "Request failed");
    if (!data.ai_overview) {
      setError("No AI Overview in this result.");
    } else {
      setOverview(data.ai_overview);
    }
  } catch (err) {
    setError(String(err.message || err));
  } finally {
    setLoading(false);
  }
}

Once the overview is set, render each block in order and finish with the references section. Keeping this mapping in one place makes it easier to work with other block types later.

// ./client/src/App.jsx
{overview && (
  <div className="space-y-4">
    <article className="prose prose-neutral">
      {overview.text_blocks.map((b, i) => (
        <BlockRenderer key={i} block={b} />
      ))}
    </article>
    
    {overview.references?.length > 0 && (
      <References references={overview.references} />
    )}
  </div>
)}

We will pass each of the text_blocks in the overview to the BlockRenderer component to handle the more complex aspects of rendering the overview.

Initial search page

Rendering Blocks

BlockRenderer uses a switch statement on block.type and returns the appropriate element.

// ./client/src/components/BlockRenderer.jsx
export default function BlockRenderer({ block }) {
  switch (block.type) {
    case "heading":
      return <h2 className="text-lg font-semibold">{block.snippet}</h2>;
    case "paragraph":
      return (
        <div>
          <p>
            {block.snippet}
            <Citations indexes={block.reference_indexes} />
          </p>
          {block.video && <VideoEmbed video={block.video} />}
        </div>
      );
    case "list":
      return <ListItems items={block.list} />;
    default:
      return null;
  }
}
💡
Note: if you later decide to highlight snippet_highlighted_words, you can add that inside the paragraph branch without affecting other blocks.

Headings

Heading blocks are used to segment sections of the overview. We simply return them semantically using an H2 element :

<h2 className="text-lg font-semibold">{block.snippet}</h2>
Overview highlighting the heading block type

Paragraphs

While headings break up the content, most of the AI Overview will come in the form of paragraphs.

<div>
  <p>
    {block.snippet}
    <Citations indexes={block.reference_indexes} />
  </p>
  {block.video && <VideoEmbed video={block.video} />}
</div>

As you can see, I've also included a component to handle any videos that may be returned in the block. In my experience, videos are typically only returned in paragraph blocks, and are not a block type, meaning we can handle them here if they exist.

Overview highlighting the paragraph block type

Lists

While lists are simple on the surface, just an unordered list with each list item being a line item, Google will often return nested lists, which could break some of our logic.

Below is a component I used to handle nested lists, being sure to include any citations or deeper lists. This component only handles lists of a depth up to 4, but can be expanded if needed.

// ./client/src/components/BlockRenderer.jsx
function ListItems({ items, depth = 0 }) {
  // Recursive list renderer for nested lists
  if (!items || items.length === 0) return null;
  const styleByDepth = [
    "list-disc pl-6",
    "list-circle pl-6",
    "list-[square] pl-6",
    "list-disc pl-6",
  ];
  const ulClass = styleByDepth[depth] || styleByDepth[styleByDepth.length - 1];
  return (
    <ul className={ulClass}>
      {items.map((item, idx) => {
        const hasNested = item.list && item.list.length > 0;
        return (
          <li key={idx} className="mt-1">
            {item.title ? <strong>{item.title} </strong> : null}
            {item.snippet ? (
              <span>
                {item.snippet}
                <Citations indexes={item.reference_indexes} />
              </span>
            ) : null}
            {hasNested ? (
              <div className="mt-1">
                <ListItems items={item.list} depth={depth + 1} />
              </div>
            ) : null}
          </li>
        );
      })}
    </ul>
  );
}
Overview highlighting the list block type

References

Finially, we need to handle our references section, which we do using a component that we pass our references array to.

// ./client/src/components/References.jsx
export default function References({ references }) {
  if (!references || references.length === 0) return null;

  return (
    <details className="mt-8">
      <summary id="references" className="text-xl font-semibold mb-2">
        References
      </summary>
      <ol className="list-decimal pl-6 space-y-2">
        {references
          .sort((a, b) => a.index - b.index)
          .map((ref) => (
            <li id={`ref-${ref.index + 1}`} key={ref.index}>
              <a
                className="underline"
                href={ref.link}
                target="_blank"
                rel="noreferrer"
              >
                {ref.title || ref.link}
              </a>
              {ref.source ? ` — ${ref.source}` : ""}
            </li>
          ))}
      </ol>
    </details>
  );
}

Closing

That’s the whole flow: request the overview with the Search API, follow up with the AI Overview API only when a short-lived page_token is returned, then render the structured result using text_blocks and references. With a thin server proxy to avoid CORS and a simple, table-driven block renderer, you get predictable output, clean citations, and an easy path to add new block types as Google evolves the format. Keep no_cache: true, make the follow-up call immediately when a token appears, process sequentially if you are batch testing, and fall back gracefully when no overview exists.

If you want to try this yourself, clone the example, plug in your SerpApi key, and test with a few real queries. From there, tweak the renderer for your preferences, add styling for snippet_highlighted_words, and extend list handling or media as you need.

Additional Resources

Google Search Engine Results API - SerpApi
Scrape Google search results in JSON format automatically using custom parameters. Search for keywords by location, date and more with SerpApi.
How to Scrape Google AI Overviews (AIO)
Scrape Google’s new AI Overviews feature (formerly called SGE) with SerpApi
AI Overviews - SerpApi
Search engine scraping tutorials, API updates and other tips.