SerpBase AI Overview 引用卡片:React 组件实战
上个月给自己的技术博客加「本文相关问答」模块时,我盯上了 Google 搜索结果页里的 AI Overview——它本身就带着一段摘要和几个来源链接,简直是现成的「带引用的答案卡片」。手工复制肯定不现实,我需要程序化地拿到这块数据。试了一圈,最后落在 SerpBase 上:它的 /google/search 响应里,ai_overview 是一个独立字段,里面有 text、sources 数组和一个 truncated 标志位,结构干净到可以直接喂给 React 组件。
这篇文章记录我把它做成一个 <AiOverviewCard /> 组件的全过程,包括服务端调用、字段语义和几个渲染上的取舍。
为什么不在前端直接调
先把架构说死:API key 不能进浏览器。SerpBase 的文档也明确写了 key 只能放在 X-API-Key 请求头里、永远不要出现在 URL 或前端代码里。所以结构是:
浏览器 → 自己的 /api/overview 路由 → api.serpbase.dev
前端组件只跟自己的路由打交道。下面这个是 Next.js 的 Route Handler,做了最基本的一层缓存:
// app/api/overview/route.ts
import { NextRequest, NextResponse } from "next/server";
const cache = new Map<string, { data: any; at: number }>();
const TTL = 1000 * 60 * 60 * 6; // 6 小时
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get("q");
if (!q) return NextResponse.json({ error: "q required" }, { status: 400 });
const hit = cache.get(q);
if (hit && Date.now() - hit.at < TTL) {
return NextResponse.json(hit.data);
}
const resp = await fetch("https://api.serpbase.dev/google/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.SERPBASE_KEY!,
},
body: JSON.stringify({ q, gl: "us", hl: "en", device: "desktop" }),
});
const json = await resp.json();
// 只向前端暴露需要的字段,别把整份 SERP 扔下去
const payload = {
aiOverview: json.ai_overview ?? null,
charged: json.credits_charged,
};
if (json.status === 0) cache.set(q, { data: payload, at: Date.now() });
return NextResponse.json(payload);
}
这里有两个只有认真读过 SerpBase 计费规则才会写的细节:
status === 0才进缓存。SerpBase 的口径是失败的请求自动退款、不计费,但失败响应里当然没有ai_overview,缓存一个空结果等于白白等 6 小时 TTL。- 我把
credits_charged透传到了响应里。一次/google/search固定 1 credit,这个字段正常返回1;如果是0说明没扣费,null说明还在事后结算中。调试期把它打在前端控制台,能立刻确认这次调用到底花没花钱,不用切去后台看余额。
组件本体:三个状态的渲染
ai_overview 不是每个查询都有。Google 没生成概览时,这个字段整个缺席。加上 truncated 标志,组件实际上有三种形态:完整卡片、截断卡片(提示用户来源更全)、空态(静默不渲染)。
// components/AiOverviewCard.tsx
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
type Source = { title: string; link: string };
type AiOverview = { text: string; sources: Source[]; truncated: boolean };
export function AiOverviewCard({ query }: { query: string }) {
const { data, isLoading } = useSWR(
`/api/overview?q=${encodeURIComponent(query)}`,
fetcher
);
if (isLoading) return <div className="aio-skeleton" aria-busy="true" />;
const aio: AiOverview | null = data?.aiOverview ?? null;
if (!aio) return null; // 该查询没有 AI Overview,整个组件消失
return (
<aside className="aio-card" aria-label="AI Overview">
<header className="aio-badge">AI Overview</header>
<p className="aio-text">{aio.text}</p>
{aio.truncated && (
<p className="aio-truncated">摘要被 Google 截断,以下来源包含完整信息</p>
)}
<ul className="aio-sources">
{aio.sources.map((s) => (
<li key={s.link}>
<a href={s.link} target="_blank" rel="noopener noreferrer">
{s.title}
</a>
</li>
))}
</ul>
</aside>
);
}
配一点最简样式(CSS 略),效果就是文章页右侧一张带「AI Overview」角标的卡片:一段答案正文,下面挂 2~4 个来源链接。
实测返回长什么样
拿 q=best coffee in austin 跑了一次(SerpBase 官网首页的互动演示里就有这个例子),ai_overview 字段的真实结构:
"ai_overview": {
"text": "Austin's best-rated cafés cluster around South Congress and East 6th; most open by 7 am and roast in house.",
"sources": [
{ "title": "Austin coffee guide 2026", "link": "https://atxcoffeeguide.com/best" },
{ "title": "r/Austin: favourite coffee?", "link": "https://reddit.com/r/Austin/..." }
],
"truncated": false
}
sources 里每个元素就是 title + link,没有多余字段要清洗。我额外测了 20 个长尾技术 query(比如 "react server components caching"),其中 13 个返回了 ai_overview,命中率比预期高;没返回的 7 个走空态分支,页面布局完全不受影响——这也是当初选字段级渲染而不是整页 iframe 的原因。
成本上,一次查询 1 credit。我的博客每天触发大约 300 次不同 query 的调用,加上 6 小时缓存去重,一天实际消耗不到 200 credits。按预付费包最低档 $0.50/千次算,一天一毛钱不到,而且 credits 不过期,失败调用还退款——对 Side Project 来说这个计费模型几乎不用动脑。
踩过的三个小坑
truncated别忽略。 一开始我没处理这个标志,结果有几张卡片的答案以半句话结尾,读者以为是 bug。截断时给一句提示并把来源链接突出显示,体验立刻正常。- query 要做规范化再当缓存 key。 大小写和首尾空格会产生不同 key,白白多扣 credit。我在 route handler 里统一
q.trim().toLowerCase()。 - 空态要返回
null而不是占位 UI。 AI Overview 是「锦上添花」模块,没有数据时静默消失,比显示「暂无内容」干净得多。
顺手能扩展的方向
同一份响应里还有 people_also_ask(问题列表)和 related_searches,我用同一个 route handler 把它们也透传了,卡片下面再加一排「相关问题」的折叠条目,零额外请求——反正一次调用 1 credit,字段多拿一个不花钱。想看完整字段定义的话,直接翻 SerpBase 的 /google/search 端点文档,每个模块的字段都列得很清楚;注册送的 500 免费 credits 不用绑卡,够你把组件从头调到上线。
如果你也在给内容站找「带出处的事实摘要」模块,别再自己接 LLM 现编了——Google 已经生成好了,把它渲染出来就行。