How to Integrate Google Search AI Overviews API into Next.js Apps 2025
Prerequisites and Environment Setup Checklist
Before writing a single line of Next.js code, get your accounts and tooling locked down. Missing any of these will cost you hours of debugging later.
Required accounts and API access
You need three things provisioned before you start:
- A Google account with Cloud Console access — billing must be enabled on your project. The Custom Search JSON API is free up to 100 queries/day; beyond that you pay per 1,000 queries.
- A Programmable Search Engine (PSE) — created at programmablesearchengine.google.com. Note your Search Engine ID (
cxparameter). - A Google Cloud API key — scoped to the
Custom Search APIonly. Never use an unrestricted key in production.
Node.js, Next.js 14+ App Router scaffold
Bootstrap with the official CLI:
npx create-next-app@latest my-ai-search --typescript --app --src-dir false
cd my-ai-search
Environment variables and secret management
Create .env.local in the project root:
GOOGLE_API_KEY=AIza...
GOOGLE_CSE_ID=012345678901234567890:abcdefghijk
These are only read server-side. Never prefix them with NEXT_PUBLIC_.
Checklist table
| Requirement | Minimum Version / Setting | Notes | |---|---|---| | Node.js | 20.x LTS | Required for native fetch | | Next.js | 14.2+ | App Router, Server Actions stable | | Google API key | Custom Search API scope only | Restrict by IP/referrer in prod | | Billing enabled | Yes (Google Cloud project) | Free tier: 100 req/day | | PSE created | Any region | Enable "Search the entire web" | | AI Overviews toggle | Enabled in PSE dashboard | See Step 1 |
Warning: The Programmable Search Engine (Custom Search JSON API) is the developer-accessible API covered in this guide. It is not the same as the AI Mode announced at Google I/O 2026 for consumer google.com Search. The PSE dashboard surfaces AI Overview–style generative responses as an opt-in feature for qualifying queries, but the underlying pipeline and SLA differ from the consumer product. Do not rely on AI Overview fields being present for every query.
Estimated time: 45 minutes
Step 1: Enable the Custom Search JSON API and Configure AI Features
This step ensures your Google Cloud project has the right API enabled and your Programmable Search Engine is configured to return generative AI response fields when available.
Creating a Programmable Search Engine with AI Overviews enabled
- Go to programmablesearchengine.google.com → Add.
- Set Search the entire web to ON.
- After creation, open Edit search engine → Search features → Generative AI. Toggle AI Overview to enabled.
- Copy the Search engine ID shown at the top of the Overview page.
Generating and scoping your API key
- APIs & Services → Library → search "Custom Search API" → Enable.
- APIs & Services → Credentials → Create Credentials → API key.
- Click Restrict Key → under API restrictions choose Custom Search API only.
Understanding AI Overview response fields
The 2025/2026 Search updates introduced a aiOverview object in Custom Search JSON API responses when a query is eligible. The field contains text (the generated summary) and references (source URLs). Eligibility depends on the PSE having the feature toggled on AND the query matching a topic Google's systems deem appropriate for generative summarization.
Test with a raw curl to confirm your setup:
curl -G "https://www.googleapis.com/customsearch/v1" \
--data-urlencode "key=YOUR_GOOGLE_API_KEY" \
--data-urlencode "cx=YOUR_CSE_ID" \
--data-urlencode "q=what is quantum entanglement" \
--data-urlencode "num=5" \
--data-urlencode "enableAIOverview=true"
A successful response shape (abbreviated):
{
"kind": "customsearch#search",
"searchInformation": {
"totalResults": "1240000000",
"searchTime": 0.312
},
"aiOverview": {
"text": "Quantum entanglement is a phenomenon where two particles become correlated such that the quantum state of each particle cannot be described independently...",
"references": [
{ "title": "Physics Today", "url": "https://physicstoday.scitation.org/..." }
]
},
"items": [
{
"title": "Quantum entanglement - Wikipedia",
"link": "https://en.wikipedia.org/wiki/Quantum_entanglement",
"snippet": "Quantum entanglement is the phenomenon where...",
"pagemap": {}
}
]
}
Note: If
aiOverviewis absent from the response, your PSE does not have the Generative AI feature enabled, or the query topic does not qualify. See the Common Issues section for the fix.
Step 2: Build a Typed Server Action to Fetch AI Search Results
Server Actions run exclusively on the server, keeping your API key out of client bundles and giving you direct access to Next.js's extended fetch caching.
Defining TypeScript interfaces
Writing the server action with error handling
Create app/actions/search.ts:
// app/actions/search.ts
'use server';
export interface AIOverviewReference {
title: string;
url: string;
}
export interface AIOverview {
text: string;
references?: AIOverviewReference[];
}
export interface SearchItem {
title: string;
link: string;
snippet: string;
displayLink: string;
pagemap?: Record<string, unknown>;
}
export interface SearchInformation {
totalResults: string;
searchTime: number;
formattedSearchTime: string;
}
export interface GoogleSearchResponse {
kind: string;
searchInformation: SearchInformation;
aiOverview?: AIOverview;
items?: SearchItem[];
error?: {
code: number;
message: string;
status: string;
};
}
export interface SearchActionResult {
data?: GoogleSearchResponse;
error?: string;
}
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY!;
const GOOGLE_CSE_ID = process.env.GOOGLE_CSE_ID!;
async function fetchWithBackoff(
url: string,
options: RequestInit,
retries = 3
): Promise<Response> {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(url, options);
if (res.status === 429) {
const delay = Math.pow(2, attempt) * 500;
await new Promise((r) => setTimeout(r, delay));
continue;
}
return res;
}
throw new Error('Max retries exceeded (429 Too Many Requests)');
}
export async function fetchAISearchResults(
query: string,
num = 5
): Promise<SearchActionResult> {
if (!query || query.trim().length === 0) {
return { error: 'Query must not be empty' };
}
// Heuristic: treat short, common queries as cacheable;
// long, unique queries skip cache to avoid stale data.
const isUniqueQuery = query.length > 60 || query.includes('"');
const params = new URLSearchParams({
key: GOOGLE_API_KEY,
cx: GOOGLE_CSE_ID,
q: query,
num: String(num),
enableAIOverview: 'true',
});
const url = `https://www.googleapis.com/customsearch/v1?${params.toString()}`;
try {
const res = await fetchWithBackoff(url, {
method: 'GET',
// Next.js extended fetch caching
next: isUniqueQuery
? { revalidate: 0 } // no-store equivalent for unique queries
: { revalidate: 60 }, // cache for 60s for popular queries
cache: isUniqueQuery ? 'no-store' : 'force-cache',
});
const json: GoogleSearchResponse = await res.json();
if (!res.ok) {
if (res.status === 403) {
const msg = json.error?.message ?? 'Forbidden';
if (msg.toLowerCase().includes('rate')) {
return { error: 'rateLimitExceeded: Daily quota reached. Upgrade your plan.' };
}
return { error: `forbidden: ${msg}` };
}
if (res.status === 400) {
return { error: `invalidQuery: ${json.error?.message ?? 'Bad request'}` };
}
return { error: `HTTP ${res.status}: ${json.error?.message}` };
}
return { data: json };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown fetch error';
return { error: message };
}
}
Note: The
'use server'directive at the top of the file marks every exported function as a Server Action. TheGOOGLE_API_KEYandGOOGLE_CSE_IDenv vars are never serialized into the client bundle.
Step 3: Build the Search UI Component with Streaming and Suspense
React Server Components let you await data directly in the component tree. Combined with <Suspense>, you get progressive rendering — the page shell loads instantly while AI Overview content streams in.
Creating the RSC search page
Create app/search/page.tsx:
// app/search/page.tsx
import { Suspense } from 'react';
import { fetchAISearchResults, SearchItem, AIOverview } from '../actions/search';
interface SearchPageProps {
searchParams: { q?: string };
}
function ResultSkeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}
async function AIOverviewCard({ query }: { query: string }) {
const result = await fetchAISearchResults(query);
if (result.error) {
return <p className="text-red-500 text-sm">Error: {result.error}</p>;
}
const overview: AIOverview | undefined = result.data?.aiOverview;
if (!overview) {
return null; // AI Overview not available for this query
}
return (
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 mb-6">
<h2 className="text-sm font-semibold text-blue-700 mb-2">AI Overview</h2>
<p className="text-gray-800 text-sm leading-relaxed">{overview.text}</p>
{overview.references && overview.references.length > 0 && (
<ul className="mt-3 space-y-1">
{overview.references.map((ref) => (
<li key={ref.url}>
<a
href={ref.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 underline"
>
{ref.title}
</a>
</li>
))}
</ul>
)}
</div>
);
}
async function SearchResults({ query }: { query: string }) {
const result = await fetchAISearchResults(query);
const items: SearchItem[] = result.data?.items ?? [];
if (items.length === 0) {
return <p className="text-gray-500">No results found.</p>;
}
return (
<ul className="space-y-5">
{items.map((item) => (
<li key={item.link}>
<a
href={item.link}
className="text-blue-700 text-lg font-medium hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{item.title}
</a>
<p className="text-green-700 text-xs">{item.displayLink}</p>
<p className="text-gray-600 text-sm mt-1">{item.snippet}</p>
</li>
))}
</ul>
);
}
export default function SearchPage({ searchParams }: SearchPageProps) {
const query = searchParams.q?.trim() ?? '';
if (!query) {
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<p className="text-gray-500">Enter a search query to get started.</p>
</main>
);
}
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6 text-gray-900">
Results for: <span className="text-blue-600">{query}</span>
</h1>
{/* AI Overview streams independently */}
<Suspense fallback={<ResultSkeleton />}>
<AIOverviewCard query={query} />
</Suspense>
{/* Traditional blue-link results */}
<Suspense fallback={<ResultSkeleton />}>
<SearchResults query={query} />
</Suspense>
</main>
);
}
Note: Both
<AIOverviewCard>and<SearchResults>are async RSCs. Placing each inside its own<Suspense>boundary means the AI Overview and the list results can resolve and render independently. The page shell (<main>and the heading) renders immediately.
Step 4: Add a Search Input with Debounced URL-State Navigation
The search page is driven by the ?q= URL parameter, which means navigation is the trigger for data fetching. The SearchBar client component updates the URL without a full page reload using the App Router's soft navigation.
Building the SearchBar component
Create app/components/SearchBar.tsx:
// app/components/SearchBar.tsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useTransition, useRef, useEffect, useState } from 'react';
const DEBOUNCE_MS = 300;
const HISTORY_KEY = 'search_history';
export function SearchBar() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [inputValue, setInputValue] = useState(
searchParams.get('q') ?? ''
);
const [history, setHistory] = useState<string[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load search history from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(HISTORY_KEY);
if (stored) setHistory(JSON.parse(stored) as string[]);
} catch {
// localStorage unavailable (SSR guard redundant here but safe)
}
}, []);
function saveToHistory(query: string) {
if (!query) return;
const updated = [query, ...history.filter((h) => h !== query)].slice(0, 10);
setHistory(updated);
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
}
function navigateToQuery(query: string) {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set('q', query);
} else {
params.delete('q');
}
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setInputValue(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
navigateToQuery(val);
}, DEBOUNCE_MS);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
if (debounceRef.current) clearTimeout(debounceRef.current);
navigateToQuery(inputValue);
saveToHistory(inputValue);
}
}
return (
<div className="w-full max-w-2xl mx-auto px-4">
<div className="relative">
<input
type="search"
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Search with AI Overview…"
className="w-full rounded-full border border-gray-300 px-5 py-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
aria-label="Search"
/>
{isPending && (
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-gray-400">
Loading…
</span>
)}
</div>
{history.length > 0 && (
<ul className="mt-2 text-xs text-gray-400 flex gap-2 flex-wrap">
{history.slice(0, 5).map((h) => (
<li
key={h}
className="cursor-pointer hover:text-blue-500"
onClick={() => {
setInputValue(h);
navigateToQuery(h);
}}
>
{h}
</li>
))}
</ul>
)}
</div>
);
}
Place <SearchBar /> in your layout or above the search page content. The 300 ms debounce fires router.push inside startTransition, which marks the navigation as non-urgent — React keeps the current UI interactive while the RSC re-fetches in the background. isPending gives you a lightweight loading indicator without a full spinner.
Note:
useSearchParams()requires the component to be wrapped in a<Suspense>boundary in Next.js 14+ when used in a layout. Wrap<SearchBar />in<Suspense fallback={null}>in your root layout to avoid the "missing Suspense boundary" build error.
Step 5: Cache and Optimize API Calls with Next.js fetch Caching
The Custom Search JSON API free tier gives you 100 queries/day. Without caching, a busy dev server burns through that in minutes. Next.js's extended fetch API lets you attach cache semantics per request — no Redis required.
Cache strategy per query type
Inside your server action (already shown in Step 2), the key lines are:
// app/actions/search.ts (caching excerpt)
// Queries longer than 60 chars or containing quoted phrases are
// treated as unique — skip cache so users get fresh results.
const isUniqueQuery = query.length > 60 || query.includes('"');
const res = await fetchWithBackoff(url, {
method: 'GET',
// next.revalidate: time in seconds before the cached entry is stale.
// cache: 'force-cache' tells Next.js to serve from cache if available.
// cache: 'no-store' bypasses cache entirely — equivalent to revalidate: 0.
...(isUniqueQuery
? { cache: 'no-store' as const }
: {
next: { revalidate: 60 }, // re-fetch in background after 60s
cache: 'force-cache' as const,
}),
});
For even more granularity, you can tag cached entries and use revalidateTag from a route handler to bust specific query caches on demand — useful if you're building a news search tool and want fresh results for trending topics.
Quota usage reference table
| Tier | Queries / Day | Cost | Notes | |---|---|---|---| | Free | 100 | $0 | Shared across all projects on the API key | | Pay-as-you-go | 100 – 10,000 | $5 per 1,000 | Billed to Google Cloud project | | Pay-as-you-go | 10,000 – 100,000+ | $5 per 1,000 | Same rate, higher volume | | SLA / Enterprise | Custom | Contact sales | Dedicated quota, SLO guarantees |
Monitor usage in Google Cloud Console → APIs & Services → Custom Search API → Quotas & System Limits. Set a budget alert at 80% of your daily quota so you're not surprised by a 429 in production.
Note:
next: { revalidate }only applies inside Next.js Server Components and Server Actions. If you ever call the Google API from a standard Node.js script or an API Route that bypasses the Next.js fetch layer, the caching semantics won't apply — use an explicit in-memory or Redis cache there instead.
Common Issues & Fixes
Error: aiOverview field missing from API response
Cause: The Programmable Search Engine either doesn't have the Generative AI feature toggled on, or the query topic doesn't qualify per Google's content policies.
Fix: In your PSE dashboard → Edit → Search features → Generative AI, confirm the AI Overview toggle is set to Enabled. Then test with a factual, informational query (e.g., "how does photosynthesis work") — opinionated or time-sensitive queries (e.g., "best laptop 2025") frequently return no AI Overview. Your code must treat the aiOverview field as optional and not crash when it's absent — the type definition in Step 2 already marks it aiOverview?.
Error: CORS error when calling Google API from a client component
Cause: You called fetch('https://www.googleapis.com/customsearch/v1?key=...') directly inside a 'use client' component. The browser blocks cross-origin requests that include an API key, and your key is exposed in the network tab.
Fix: Move all Google API calls into Server Actions ('use server') or Route Handlers (app/api/search/route.ts). The client component should call your Server Action via form action or startTransition, never the Google endpoint directly.
// ❌ Wrong — exposes key, triggers CORS
const res = await fetch(`https://www.googleapis.com/customsearch/v1?key=${process.env.NEXT_PUBLIC_KEY}`);
// ✅ Correct — call the server action from the client
import { fetchAISearchResults } from '@/app/actions/search';
const result = await fetchAISearchResults(query);
Error: 429 quota exceeded during local development
Cause: Hot-module reload triggers component re-renders and re-executes Server Actions on every file save, burning through your 100 free queries quickly.
Fix: Add a local file-system mock during development. Create lib/mockSearchResponse.ts that returns a hardcoded GoogleSearchResponse and check process.env.NODE_ENV === 'development' at the top of your server action:
if (process.env.NODE_ENV === 'development' && process.env.USE_MOCK_SEARCH === 'true') {
return { data: mockSearchResponse };
}
Set USE_MOCK_SEARCH=true in .env.local during dev.
Error: TypeScript errors on optional chaining through nested response objects
Cause: The aiOverview, references, and items fields are all optional. Accessing result.data.aiOverview.text without optional chaining throws a type error at compile time (and a runtime error if the API returns no aiOverview).
Fix: Use optional chaining and nullish coalescing:
const summaryText = result.data?.aiOverview?.text ?? 'No AI summary available';
const firstRef = result.data?.aiOverview?.references?.[0]?.url ?? '#';
| Error symptom | Root cause | Fix |
|---|---|---|
| aiOverview missing | PSE AI feature disabled or non-qualifying query | Enable in PSE dashboard; use informational queries |
| CORS blocked in browser | API called from client component | Move to Server Action or Route Handler |
| 429 Too Many Requests | Free tier quota exhausted | Use mock in dev; set revalidate for prod |
| TS error on aiOverview.text | Accessing possibly-undefined nested property | Optional chain: result.data?.aiOverview?.text |
FAQ
Q: Is the Google Custom Search JSON API the same as the AI Search announced at Google I/O 2026?
No — they are distinct products. The AI Mode on google.com (announced at Google I/O 2026) is a consumer-facing feature powered by Gemini that's deeply integrated into Google Search's production infrastructure. The Custom Search JSON API (Programmable Search Engine) is a developer API that has existed since 2010 and now surfaces an optional aiOverview field for qualifying queries. Developers do not have direct API access to the consumer AI Mode; the PSE API is the supported programmatic path as of mid-2025. Monitor the Google Developers Blog for any future Search API products that expose the full AI Mode capability.
Q: How do I avoid exposing my Google API key in client-side Next.js bundles?
Store the key in .env.local without the NEXT_PUBLIC_ prefix. Next.js only inlines variables prefixed with NEXT_PUBLIC_ into the client bundle at build time. Any variable without that prefix is exclusively available in the Node.js runtime — Server Components, Server Actions, Route Handlers, and getServerSideProps. Confirm your key stays server-side by running grep -r 'GOOGLE_API_KEY' .next/static after a build; you should get no results. Additionally, restrict the key in Google Cloud Console to only the Custom Search API and — in production — to your server's IP address.
Q: Can I use this approach with the Next.js Pages Router instead of App Router?
Yes, with minor adjustments. Server Actions are an App Router–only feature, so in the Pages Router you'd replace the server action with an API Route (pages/api/search.ts). Your client component would call fetch('/api/search?q=...') instead of invoking the server action directly. The TypeScript interfaces, caching strategy, and error handling logic are identical — only the file location and invocation pattern change. The next: { revalidate } fetch option also works inside Pages Router API Routes since it's part of Next.js's patched fetch. The App Router approach is recommended for new projects because co-locating data fetching in RSCs eliminates the extra client→API round trip.