By the time you wire a SERP API into a real product, you've usually made the same deal three times in three files: a fetch call, a type assertion, and a prayer that the response shape matches. This post collapses all three into one small, dependency-free client. ~80 lines of TypeScript gives you typed organic results, discriminated errors, and a call site that reads like const results = await serp.search({ q }).
Types first, code second
The response has two layers worth modeling: the envelope (status, timings, credits) and the payload (the organic array). Model both:
// serp.ts
export interface SearchParams {
q: string;
hl?: string; // defaults to "en" server-side
gl?: string; // defaults to "us" server-side
page?: number; // 1-based
device?: "default" | "pc" | "mobile"; // search endpoint only
}
export interface OrganicResult {
rank: number; // 1-based position within this response
title: string;
link: string; // always present
display_url?: string;
snippet?: string;
date?: string; // raw date text as extracted from the snippet
}
export interface SearchOk {
status: 0;
request_id: string;
elapsed_ms: number;
credits_charged: number;
organic: OrganicResult[];
}
export interface SerpError {
status: number; // non-zero
error: string;
request_id?: string;
credits_charged?: 0;
}
export type SearchResponse = SearchOk | SerpError;
Two deliberate choices: status: 0 on the success branch turns the envelope into a discriminated union, so every consumer must narrow before touching organic. And link is required while snippet is optional — that matches the documented field table for the SerpBase search endpoint docs, where link is the only guaranteed field per result.
The client
const API_URL = "https://api.serpbase.dev/google/search";
export class SerpApiError extends Error {
constructor(
public code: number,
public requestId: string | undefined,
message: string,
) {
super(`SERP API error ${code}: ${message}`);
this.name = "SerpApiError";
}
}
export function createClient(apiKey = process.env.SERPBASE_API_KEY!) {
async function search(params: SearchParams): Promise<SearchOk> {
if (!params.q?.trim()) throw new RangeError("q is required");
const resp = await fetch(API_URL, {
method: "POST",
headers: {
"X-API-Key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
const data = (await resp.json()) as SearchResponse;
if (data.status !== 0) {
throw new SerpApiError(data.status, data.request_id, data.error);
}
return data;
}
/** First organic hit for a domain, or undefined. */
async function positionOf(q: string, domain: string) {
const { organic } = await search({ q });
return organic.find((r) => r.link.includes(domain));
}
return { search, positionOf };
}
The call site — the part your teammates will read a hundred times:
const serp = createClient();
const best = await serp.positionOf("best mechanical keyboard", "example.com");
if (best) console.log(`#${best.rank}: ${best.title}`);
Reading the error surface
The error codes worth branching on in application code are few: 1001 means your key is wrong, 1020 means the account is out of credits, and 1029 means rate limiting — the only one that merits automatic retry, with backoff. Everything else (upstream timeouts, malformed requests) deserves a logged request_id and a failed job, not a silent retry.
Note what the client does not do: it doesn't retry on status !== 0 responses that consumed no credits (credits_charged is 0 on errors), and it doesn't hide failures behind undefined. Errors here are loud so that dashboards can't quietly show stale data.
Test it without spending credits
Inject the fetch or run against a recorded fixture — one JSON file per query is enough:
import { search } from "./fixture"; // recorded response
const client = createClient("test-key");
jest.spyOn(global, "fetch").mockResolvedValue(new Response(JSON.stringify(search)));
Because the parser boundary is a single function from Response to SearchOk, one fixture per status covers the whole unit surface. Keep a single live test (mirroring the fixture approach) to catch schema drift.
FAQ
Why not generate types from the docs with an OpenAPI spec? If the API published one, sure. Hand-rolled interfaces for a single endpoint are cheaper than a codegen pipeline — and the field table is small enough to review by eye.
Why throw instead of returning a Result type? For an internal client, exceptions at the boundary are fine; the discriminated union already forces narrowing on the shape check. If you're building a library, swap the throw for Result<SearchOk, SerpApiError> — the union type stays the same.
What about the other endpoints (news, images, maps)? Same envelope, different payload key and params. Copy the SearchOk interface, rename organic to news/images/places, and tighten the param type — the structure repeats.
Start with the 80 lines above, keep the fixtures next to the client, and resist the urge to wrap it in a class hierarchy — a function and a union type are the whole design.
Top comments (0)