Build a Search Analytics App with Next.js and Serpent API

By Serpent API Team· · 12 min read

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, DuckDuckGo, and Brave 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

LayerTechnologyPurpose
FrameworkNext.js 14+ (App Router)SSR, routing, API routes
LanguageTypeScriptType safety
StylingTailwind CSSUtility-first CSS
ChartsRechartsData visualization
DataSerpent APISearch results
DeploymentVercelHosting 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;
  displayedUrl?: string;
}

export interface SerpResponse {
  success: boolean;
  query: string;
  type: string;
  engine: string;
  country: string;
  results: {
    organic: OrganicResult[];
    ads?: { top: OrganicResult[]; bottom: OrganicResult[]; totalCount: number };
    peopleAlsoAsk?: { question: string; answer: string }[];
    relatedSearches?: string[];
    featuredSnippet?: { text: string; sourceUrl: string; sourceTitle: string } | null;
  };
  meta: {
    totalOrganic: number;
    elapsed: string;
  };
}

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" | "brave") => void;
}

export function EngineSelector({ value, onChange }: Props) {
  const engines = [
    { id: "google", label: "Google" },
    { id: "yahoo", label: "Yahoo" },
    { id: "bing", label: "Bing" },
    { id: "ddg", label: "DuckDuckGo" },
    { id: "brave", label: "Brave" },
  ] 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.engine}</span>
        <span>Results: {organic.length}</span>
        <span>Time: {data.meta.elapsed}</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/ratelimit with 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

ComponentMonthly Cost
Vercel Hobby (hosting)Free
Serpent API (1,000 searches/mo)$0.03
Serpent API (10,000 searches/mo)$0.30
Upstash Redis (caching)Free tier

A fully functional search analytics app running in production for well under a dollar a month. That is the advantage of building on affordable infrastructure.

FAQ

Can I use Serpent API with Next.js App Router?

Yes, and the App Router is what this tutorial targets. Every search call goes through a Route Handler or a Server Component, so the request leaves your server rather than the browser and the key never ships to the client. Nothing about the API is React-specific — it is a plain HTTPS GET — so the older Pages Router works exactly the same way. The only thing that changes between the two is where you put the fetch.

How do I keep my Serpent API key secure in a Next.js app?

Put it in .env.local and read it only from server-side code: Route Handlers, Server Components, or server actions. Next.js exposes an environment variable to the browser only when its name begins with NEXT_PUBLIC_, so a variable named SERPENT_API_KEY stays server-side by construction. Never call the API from a client component or a useEffect hook — anything the browser can execute, a visitor can read. If you have already shipped a key to the client at any point, treat it as public and issue a new one.

What charting library works best with Next.js for SERP data?

Recharts is the safe default. It is built for React rather than wrapped for it, its ResponsiveContainer handles resizing without extra code, and it behaves predictably alongside server-rendered content. Chart.js with the react-chartjs-2 wrapper is lighter and perfectly fine for plain bar and line charts. Whichever you choose, put the chart in a client component and keep the data fetching on the server — that keeps the charting bundle out of your server code and off the critical path.

How do I cache SERP API results in Next.js?

Start with the built-in fetch cache: pass next: { revalidate: 3600 } to hold a response for an hour, or 86400 for a day. Choose the number from how fast the data actually moves — rank tracking tolerates a day, breaking-news queries tolerate minutes. When you outgrow that, move to a shared store such as Redis so every serverless instance reads the same cache instead of each one warming its own. An in-process cache like node-cache is fine in development but buys you almost nothing on serverless, where each invocation can be a cold process.

Can I deploy this Next.js SERP app to Vercel for free?

Yes. The Hobby plan runs Next.js apps with serverless functions, and the search calls happen inside Route Handlers, so there is no separate backend to host. Add SERPENT_API_KEY in Vercel's Environment Variables panel rather than committing it, and set it for preview and production if you use preview deploys. Watch the function execution limit: a handler that runs many searches one after another can hit it, so fan those calls out in parallel or split them across requests.

Start Building with Serpent API

Get your API key and start building your Next.js search app today. 10 free web searches included, no credit card required.

Get Your Free API Key

Explore: SERP API · Google Search API · Pricing · Try in Playground