Modern Node.js includes the pieces needed to request Google Search results directly: fetch for HTTP, URL for safe query construction, and AbortSignal for timeouts.
Reserp's HTTP endpoint accepts a complete Google Search URL and returns visible result blocks as JSON. The client code can therefore remain small and explicit.
Create the Google Search URL
Store the API key in an environment variable:
export RESERP_API_KEY="your-api-key"
Then build search URLs with the standard URL class:
const endpoint = "https://api.reserp.ai/v1/serp";
const apiKey = process.env.RESERP_API_KEY;
if (!apiKey) {
throw new Error("RESERP_API_KEY is missing");
}
function googleSearchUrl(
query,
{ country = "us", language = "en", start = 0 } = {},
) {
if (!Number.isInteger(start) || start < 0 || start % 10 !== 0) {
throw new Error("start must be a non-negative multiple of 10");
}
const url = new URL("https://www.google.com/search");
url.searchParams.set("q", query);
url.searchParams.set("gl", country);
url.searchParams.set("hl", language);
if (start) url.searchParams.set("start", String(start));
return url.toString();
}
Using URLSearchParams prevents punctuation and non-English queries from corrupting the URL. It also makes the submitted country, language, and page offset visible in the code and any saved collection metadata.
Make one direct request
async function fetchGoogleSearch(url) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url }),
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Search returned non-JSON HTTP ${response.status}`);
}
if (!response.ok || data.ok !== true) {
const error = new Error(data.error ?? `HTTP ${response.status}`);
error.retryable = data.retryable === true;
error.billed = data.billed === true;
throw error;
}
return data;
}
const page = await fetchGoogleSearch(
googleSearchUrl("renewable energy storage research", {
country: "gb",
language: "en",
}),
);
console.log(page.results);
Reading the response as text before parsing gives the application control over malformed or empty bodies. HTTP 200 is not the only condition worth checking; the successful public response also has ok: true.
Describe the response in TypeScript
The result structure is recursive because a visible block can contain child blocks:
type SearchResult = {
text?: string;
url?: string;
children?: SearchResult[];
};
type SearchSuccess = {
ok: true;
url: string;
finalUrl: string;
results: SearchResult[];
pagination: {
start: number;
nextStart: number;
nextUrl: string;
};
billed: boolean;
};
type SearchFailure = {
ok: false;
error:
| "invalid_request"
| "authentication_failed"
| "free_allowance_exhausted"
| "request_not_allowed"
| "rate_limited"
| "internal_error"
| "search_failed"
| "service_unavailable";
retryable: boolean;
billed: boolean;
};
Treat the live API documentation as the source of truth if the public contract changes.
Traverse nested results
Flattening only the first array level can omit sitelinks and destinations within visible modules.
function* walkResults(results = []) {
for (const result of results) {
yield result;
yield* walkResults(result.children ?? []);
}
}
const destinations = [...walkResults(page.results)]
.filter((result) => result.url)
.map((result) => ({
url: result.url,
text: result.text ?? "",
}));
Keep the unmodified tree as well as any flat table derived from it. The tree records where child destinations came from; a flat array is convenient for counting and storage.
Follow the supplied pagination URL
const secondPage = await fetchGoogleSearch(page.pagination.nextUrl);
Google's next page advances by an organic-result offset. It does not advance by the number of visible blocks or recursively extracted URLs. Following pagination.nextUrl retains the original query parameters and avoids skipped or repeated offsets.
Add bounded retries
Not every error should be retried. Invalid input and failed authentication need correction. Transient failures may be retried when the response marks them as retryable.
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function fetchWithRetry(url, maxAttempts = 4) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await fetchGoogleSearch(url);
} catch (error) {
const finalAttempt = attempt === maxAttempts - 1;
if (!error.retryable || finalAttempt) throw error;
const base = Math.min(12_000, 750 * (2 ** attempt));
await wait(base + Math.random() * 500);
}
}
}
A fuller client can honor the Retry-After header for rate limits, but it should still cap attempts and total elapsed time. Retried requests must pass through the same request-rate controls as first attempts.
Keep collection and analysis separate
For auditable work, save the query, country, language, collection timestamp, raw response, and application version together. Perform URL normalization and deduplication in a derived step.
The result is intentionally ordinary JavaScript: construct a URL, send one authenticated HTTP request, validate the JSON, and retain the response structure. See the Google Search API documentation for the complete schema and current error codes.