Build a Search Analytics App with Next.js and Serpent API
Next.js is the most popular React framework for building production web applications. Its built-in API routes, server-side rendering, and edge functions make it an ideal choice for building tools that consume external APIs. In this tutorial, we will build a complete search analytics dashboard that lets users enter keywords, view SERP results from multiple engines, and visualize ranking data with interactive charts.
The finished app fetches data from Serpent API on the server side, keeping your API key secure while providing fast, cached responses to the frontend. We will use the Next.js App Router, TypeScript, Tailwind CSS for styling, and Recharts for data visualization.
What We Are Building
The search analytics app will have four main features:
- Multi-engine search — Query Google, Yahoo/Bing, and DuckDuckGo from a single interface
- SERP feature breakdown — Display organic results, ads, People Also Ask, related searches, and featured snippets
- Ranking distribution chart — Visualize where results cluster by domain
- SERP comparison view — Compare results across engines side by side
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 14+ (App Router) | SSR, routing, API routes |
| Language | TypeScript | Type safety |
| Styling | Tailwind CSS | Utility-first CSS |
| Charts | Recharts | Data visualization |
| Data | Serpent API | Search results |
| Deployment | Vercel | Hosting and serverless |
Project Setup
Start by creating a new Next.js project with TypeScript and Tailwind:
npx create-next-app@latest serp-analytics --typescript --tailwind --app --src-dir
cd serp-analytics
npm install recharts
Create a .env.local file for your Serpent API key:
# .env.local
SERPENT_API_KEY=your_api_key_here
SERPENT_API_URL=https://apiserpent.com
Because the variable name does not start with NEXT_PUBLIC_, Next.js will never expose it to the browser. Your API key stays on the server.
Type Definitions
Create a types file to define the shape of Serpent API responses:
// src/types/serp.ts
export interface OrganicResult {
position: number;
title: string;
url: string;
snippet: string;
displayUrl?: string;
}
export interface SerpResponse {
searchParameters: {
q: string;
engine: string;
num: number;
};
results: {
organic: OrganicResult[];
ads?: OrganicResult[];
peopleAlsoAsk?: { question: string; answer: string }[];
relatedSearches?: string[];
featuredSnippet?: { title: string; text: string; url: string };
};
metadata: {
totalResults: number;
timeTaken: number;
};
}
export interface SearchRequest {
query: string;
engine: "google" | "yahoo" | "ddg";
num?: number;
}
Creating the API Route Handler
The Route Handler acts as a proxy between your frontend and Serpent API. It adds your API key server-side, validates the request, and returns the results:
// src/app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
const SERPENT_API_KEY = process.env.SERPENT_API_KEY;
const SERPENT_API_URL = process.env.SERPENT_API_URL || "https://apiserpent.com";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
const engine = searchParams.get("engine") || "google";
const num = searchParams.get("num") || "10";
if (!query) {
return NextResponse.json(
{ error: "Query parameter 'q' is required" },
{ status: 400 }
);
}
if (!SERPENT_API_KEY) {
return NextResponse.json(
{ error: "API key not configured" },
{ status: 500 }
);
}
try {
const url = new URL(`${SERPENT_API_URL}/api/search`);
url.searchParams.set("q", query);
url.searchParams.set("engine", engine);
url.searchParams.set("num", num);
url.searchParams.set("apiKey", SERPENT_API_KEY);
const response = await fetch(url.toString(), {
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!response.ok) {
throw new Error(`Serpent API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Search API error:", error);
return NextResponse.json(
{ error: "Failed to fetch search results" },
{ status: 502 }
);
}
}
The next: { revalidate: 3600 } option tells Next.js to cache the response for one hour. Subsequent requests for the same query and engine will be served from cache, dramatically reducing API calls and improving response times.
Server-Side Caching
Next.js fetch caching is good for simple cases, but a search analytics app benefits from a more explicit caching layer. Here is an in-memory cache using a Map with TTL expiry:
// src/lib/cache.ts
interface CacheEntry<T> {
data: T;
expiry: number;
}
class SearchCache {
private cache = new Map<string, CacheEntry<unknown>>();
private defaultTTL: number;
constructor(ttlSeconds: number = 3600) {
this.defaultTTL = ttlSeconds * 1000;
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
set<T>(key: string, data: T, ttlMs?: number): void {
this.cache.set(key, {
data,
expiry: Date.now() + (ttlMs || this.defaultTTL),
});
}
makeKey(query: string, engine: string, num: number): string {
return `${engine}:${query.toLowerCase().trim()}:${num}`;
}
}
export const searchCache = new SearchCache(3600); // 1-hour TTL
Then update your Route Handler to check cache first:
// In src/app/api/search/route.ts, update the GET handler:
import { searchCache } from "@/lib/cache";
// Inside GET handler, before the fetch call:
const cacheKey = searchCache.makeKey(query, engine, parseInt(num));
const cached = searchCache.get(cacheKey);
if (cached) {
return NextResponse.json(cached);
}
// After successful fetch:
searchCache.set(cacheKey, data);
return NextResponse.json(data);
For production deployments with multiple serverless instances, replace the in-memory cache with Redis using @upstash/redis, which works well with Vercel's serverless architecture.
Building the Search Interface
The search page is a Client Component that manages query state and fetches from your API route:
// src/app/page.tsx
"use client";
import { useState } from "react";
import { SerpResponse } from "@/types/serp";
import { SearchResults } from "@/components/SearchResults";
import { EngineSelector } from "@/components/EngineSelector";
export default function Home() {
const [query, setQuery] = useState("");
const [engine, setEngine] = useState<"google" | "yahoo" | "ddg">("google");
const [results, setResults] = useState<SerpResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setError("");
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}&engine=${engine}&num=20`
);
if (!res.ok) throw new Error("Search failed");
const data: SerpResponse = await res.json();
setResults(data);
} catch (err) {
setError("Failed to fetch results. Please try again.");
} finally {
setLoading(false);
}
}
return (
<main className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Search Analytics</h1>
<form onSubmit={handleSearch} className="flex gap-3 mb-8">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter a keyword..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<EngineSelector value={engine} onChange={setEngine} />
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-teal-600 text-white rounded-lg"
>
{loading ? "Searching..." : "Search"}
</button>
</form>
{error && <p className="text-red-500 mb-4">{error}</p>}
{results && <SearchResults data={results} />}
</main>
);
}
Engine Selector Component
// src/components/EngineSelector.tsx
interface Props {
value: string;
onChange: (engine: "google" | "yahoo" | "ddg") => void;
}
export function EngineSelector({ value, onChange }: Props) {
const engines = [
{ id: "google", label: "Google" },
{ id: "yahoo", label: "Yahoo/Bing" },
{ id: "ddg", label: "DuckDuckGo" },
] as const;
return (
<select
value={value}
onChange={(e) => onChange(e.target.value as any)}
className="px-3 py-2 border rounded-lg bg-white"
>
{engines.map((e) => (
<option key={e.id} value={e.id}>{e.label}</option>
))}
</select>
);
}
Displaying SERP Results
The results component renders organic listings, ads, People Also Ask, and related searches:
// src/components/SearchResults.tsx
import { SerpResponse } from "@/types/serp";
export function SearchResults({ data }: { data: SerpResponse }) {
const { organic, ads, peopleAlsoAsk, relatedSearches, featuredSnippet } =
data.results;
return (
<div className="space-y-6">
{/* Metadata bar */}
<div className="flex gap-4 text-sm text-gray-500">
<span>Engine: {data.searchParameters.engine}</span>
<span>Results: {organic.length}</span>
<span>Time: {data.metadata.timeTaken}ms</span>
</div>
{/* Featured Snippet */}
{featuredSnippet && (
<div className="p-4 bg-teal-50 border border-teal-200 rounded-lg">
<span className="text-xs font-semibold text-teal-700">
Featured Snippet
</span>
<h3 className="font-bold mt-1">{featuredSnippet.title}</h3>
<p className="text-sm mt-1">{featuredSnippet.text}</p>
<a href={featuredSnippet.url} className="text-xs text-teal-600">
{featuredSnippet.url}
</a>
</div>
)}
{/* Organic Results */}
<div>
<h2 className="text-lg font-semibold mb-3">Organic Results</h2>
{organic.map((result) => (
<div key={result.position} className="mb-4 p-3 border rounded-lg">
<div className="flex items-center gap-2">
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
#{result.position}
</span>
<a href={result.url} className="text-blue-600 font-medium">
{result.title}
</a>
</div>
<p className="text-sm text-green-700 mt-1">{result.url}</p>
<p className="text-sm text-gray-600 mt-1">{result.snippet}</p>
</div>
))}
</div>
{/* People Also Ask */}
{peopleAlsoAsk && peopleAlsoAsk.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">People Also Ask</h2>
{peopleAlsoAsk.map((paa, i) => (
<details key={i} className="mb-2 border rounded-lg">
<summary className="p-3 cursor-pointer font-medium">
{paa.question}
</summary>
<p className="px-3 pb-3 text-sm text-gray-600">
{paa.answer}
</p>
</details>
))}
</div>
)}
</div>
);
}
Adding Charts and Analytics
The real value of a search analytics app lies in visualization. We will add two charts: a domain distribution pie chart and a SERP feature breakdown bar chart.
// src/components/DomainChart.tsx
"use client";
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
import { OrganicResult } from "@/types/serp";
const COLORS = [
"#0d9488", "#0891b2", "#6366f1", "#d946ef",
"#f59e0b", "#ef4444", "#22c55e", "#64748b"
];
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace("www.", "");
} catch {
return url;
}
}
export function DomainChart({ results }: { results: OrganicResult[] }) {
// Count domains
const domainCounts: Record<string, number> = {};
results.forEach((r) => {
const domain = extractDomain(r.url);
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
});
const chartData = Object.entries(domainCounts)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 8);
return (
<div className="h-64">
<h3 className="text-md font-semibold mb-2">Domain Distribution</h3>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={chartData} dataKey="value" nameKey="name" cx="50%" cy="50%">
{chartData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
);
}
SERP Feature Breakdown
// src/components/SerpFeatureChart.tsx
"use client";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
} from "recharts";
import { SerpResponse } from "@/types/serp";
export function SerpFeatureChart({ data }: { data: SerpResponse }) {
const features = [
{ name: "Organic", count: data.results.organic?.length || 0 },
{ name: "Ads", count: data.results.ads?.length || 0 },
{ name: "PAA", count: data.results.peopleAlsoAsk?.length || 0 },
{ name: "Related", count: data.results.relatedSearches?.length || 0 },
{ name: "Snippet", count: data.results.featuredSnippet ? 1 : 0 },
].filter((f) => f.count > 0);
return (
<div className="h-64">
<h3 className="text-md font-semibold mb-2">SERP Features</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={features}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#0d9488" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
These charts update automatically whenever the user performs a new search. For historical tracking, you would add a database layer (Prisma + PostgreSQL or Supabase) to store results over time and chart ranking trends.
Deployment to Vercel
Deploying to Vercel is straightforward since Next.js is their first-class framework:
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Set environment variable
vercel env add SERPENT_API_KEY
Production Checklist
- Rate limiting — Add rate limiting to your API route to prevent abuse. Use
@upstash/ratelimitwith Vercel KV for a serverless-friendly solution. - Error boundaries — Wrap your search components in React Error Boundaries to handle API failures gracefully.
- Loading states — Use React Suspense and loading.tsx files for a smooth user experience during server-side data fetching.
- SEO meta tags — Use Next.js Metadata API to add dynamic titles and descriptions to search result pages.
- Analytics — Track which queries users run most frequently to optimize caching and understand usage patterns.
Cost Estimate
| Component | Monthly Cost |
|---|---|
| Vercel Hobby (hosting) | Free |
| Serpent API (1,000 searches/mo) | $0.05 |
| Serpent API (10,000 searches/mo) | $0.50 |
| Upstash Redis (caching) | Free tier |
A fully functional search analytics app running in production for under $5 per month. That is the advantage of building on affordable infrastructure.
Start Building with Serpent API
Get your API key and start building your Next.js search app today. 100 free searches included, no credit card required.
Get Your Free API KeyExplore: SERP API · Google Search API · Pricing · Try in Playground