Skip to content

How to Add Instant Search to a Next.js App (Complete Guide)

Alex Chibilyaev

5/3/2026

#nextjs#tutorial#search#developer#react
How to Add Instant Search to a Next.js App (Complete Guide)

Search is one of those features that looks simple until you build it. A naive implementation (filtering an array, querying a database with LIKE) works fine at 100 items. At 10,000 items — or with users typing fast and expecting instant results — it breaks down immediately.

This guide covers adding production-quality instant search to a Next.js application using AACSearch (AACSearch-backed hosted search). The full setup takes about 30 minutes.

What We're Building

  • A search index for your content (products, articles, users — any structured data)
  • A server-side route to proxy search requests securely
  • A client-side search UI with instant results, keyboard navigation, and typo tolerance
  • Analytics tracking (zero-result queries, result clicks)

Prerequisites

  • Next.js 14+ with App Router
  • TypeScript
  • An AACSearch account (free tier is enough)

Step 1: Create a Search Index

In the AACSearch dashboard, create a new index. For a blog or documentation site:

{
	"name": "articles",
	"fields": [
		{ "name": "id", "type": "string" },
		{ "name": "title", "type": "string" },
		{ "name": "content", "type": "string" },
		{ "name": "excerpt", "type": "string" },
		{ "name": "category", "type": "string", "facet": true },
		{ "name": "tags", "type": "string[]", "facet": true },
		{ "name": "published_at", "type": "int64" }
	],
	"default_sorting_field": "published_at"
}

For an e-commerce product catalog:

{
	"name": "products",
	"fields": [
		{ "name": "id", "type": "string" },
		{ "name": "title", "type": "string" },
		{ "name": "description", "type": "string" },
		{ "name": "price", "type": "float" },
		{ "name": "category", "type": "string", "facet": true },
		{ "name": "in_stock", "type": "bool", "facet": true }
	],
	"default_sorting_field": "price"
}

Step 2: Index Your Content

Install the AACSearch Node.js client:

npm install @AACSearch/client
# or
pnpm add @AACSearch/client

Create an indexing script at scripts/index-content.ts:

import { AACSearchClient } from "@AACSearch/client";

const client = new AACSearchClient({
	apiUrl: process.env.AACSEARCH_API_URL!,
	apiKey: process.env.AACSEARCH_INGEST_KEY!, // write key, server-side only
});

async function indexArticles() {
	// Replace with your actual data source
	const articles = await fetchAllArticles();

	const documents = articles.map((article) => ({
		id: article.slug,
		title: article.title,
		content: article.content,
		excerpt: article.excerpt,
		category: article.category,
		tags: article.tags,
		published_at: Math.floor(new Date(article.publishedAt).getTime() / 1000),
	}));

	await client.documents("articles").importBatch(documents, {
		action: "upsert", // create or update
	});

	console.log(`Indexed ${documents.length} articles`);
}

indexArticles().catch(console.error);

Run it:

npx tsx scripts/index-content.ts

For production, trigger reindexing from a webhook when content changes (e.g., on CMS publish events).

Step 3: Create a Server-Side Search Route

Never expose your search API key directly to the browser. Create a Next.js Route Handler that proxies search requests:

// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";

const AACSEARCH_API_URL = process.env.AACSEARCH_API_URL!;
const AACSEARCH_SEARCH_KEY = process.env.AACSEARCH_SEARCH_KEY!; // read-only key

export async function GET(request: NextRequest) {
	const { searchParams } = new URL(request.url);
	const query = searchParams.get("q");

	if (!query || query.trim().length === 0) {
		return NextResponse.json({ hits: [], found: 0 });
	}

	const response = await fetch(
		`${AACSEARCH_API_URL}/api/search?` +
			new URLSearchParams({
				q: query,
				query_by: "title,excerpt,content",
				per_page: "10",
				highlight_full_fields: "title,excerpt",
			}),
		{
			headers: {
				Authorization: `Bearer ${AACSEARCH_SEARCH_KEY}`,
			},
		},
	);

	if (!response.ok) {
		return NextResponse.json({ error: "search_failed" }, { status: 502 });
	}

	const data = await response.json();
	return NextResponse.json(data);
}

Add the env vars to .env.local:

AACSEARCH_API_URL=https://api.AACSearch.com
AACSEARCH_SEARCH_KEY=ss_search_your_read_only_key
AACSEARCH_INGEST_KEY=ss_connector_your_write_key

Step 4: Build the Search UI Component

Create a client component for the search interface:

// components/search/SearchModal.tsx
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";

interface SearchHit {
  document: {
    id: string;
    title: string;
    excerpt: string;
    category: string;
  };
  highlights: Array<{
    field: string;
    snippet: string;
  }>;
}

interface SearchResults {
  hits: SearchHit[];
  found: number;
}

export function SearchModal({
  onClose,
}: {
  onClose: () => void;
}) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResults | null>(null);
  const [loading, setLoading] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const router = useRouter();

  // Focus input on mount
  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  // Debounced search
  useEffect(() => {
    if (!query.trim()) {
      setResults(null);
      return;
    }

    const timer = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(
          `/api/search?q=${encodeURIComponent(query)}`
        );
        const data = await res.json();
        setResults(data);
        setSelectedIndex(0);
      } finally {
        setLoading(false);
      }
    }, 150); // 150ms debounce — fast enough to feel instant

    return () => clearTimeout(timer);
  }, [query]);

  // Keyboard navigation
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      const hits = results?.hits ?? [];

      if (e.key === "Escape") {
        onClose();
      } else if (e.key === "ArrowDown") {
        e.preventDefault();
        setSelectedIndex((i) => Math.min(i + 1, hits.length - 1));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setSelectedIndex((i) => Math.max(i - 1, 0));
      } else if (e.key === "Enter" && hits[selectedIndex]) {
        router.push(`/articles/${hits[selectedIndex].document.id}`);
        onClose();
      }
    },
    [results, selectedIndex, router, onClose]
  );

  const getHighlight = (hit: SearchHit, field: string) => {
    const h = hit.highlights.find((h) => h.field === field);
    return h?.snippet ?? hit.document[field as keyof typeof hit.document];
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] bg-black/50"
      onClick={onClose}
    >
      <div
        className="w-full max-w-xl bg-background rounded-xl shadow-2xl border border-border overflow-hidden"
        onClick={(e) => e.stopPropagation()}
        onKeyDown={handleKeyDown}
      >
        {/* Search input */}
        <div className="flex items-center px-4 border-b border-border">
          <svg
            className="w-4 h-4 text-muted-foreground shrink-0"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
            />
          </svg>
          <input
            ref={inputRef}
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search..."
            className="flex-1 px-3 py-4 bg-transparent outline-none text-sm"
          />
          {loading && (
            <div className="w-4 h-4 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
          )}
        </div>

        {/* Results */}
        {results && results.hits.length > 0 && (
          <ul className="max-h-80 overflow-y-auto py-2">
            {results.hits.map((hit, i) => (
              <li key={hit.document.id}>
                <button
                  className={`w-full text-left px-4 py-3 hover:bg-muted transition-colors ${
                    i === selectedIndex ? "bg-muted" : ""
                  }`}
                  onClick={() => {
                    router.push(`/articles/${hit.document.id}`);
                    onClose();
                  }}
                >
                  <p
                    className="text-sm font-medium"
                    dangerouslySetInnerHTML={{
                      __html: getHighlight(hit, "title"),
                    }}
                  />
                  <p
                    className="text-xs text-muted-foreground mt-0.5 line-clamp-1"
                    dangerouslySetInnerHTML={{
                      __html: getHighlight(hit, "excerpt"),
                    }}
                  />
                </button>
              </li>
            ))}
          </ul>
        )}

        {/* Zero results */}
        {results && results.hits.length === 0 && query.trim() && (
          <div className="px-4 py-8 text-center text-sm text-muted-foreground">
            No results for &ldquo;{query}&rdquo;
          </div>
        )}

        {/* Footer */}
        <div className="px-4 py-2 border-t border-border flex gap-3 text-xs text-muted-foreground">
          <span>↑↓ navigate</span>
          <span>↵ open</span>
          <span>esc close</span>
        </div>
      </div>
    </div>
  );
}

Step 5: Wire Up the Trigger

Add a search button to your layout that opens the modal:

// components/search/SearchButton.tsx
"use client";

import { useState, useEffect } from "react";
import { SearchModal } from "./SearchModal";

export function SearchButton() {
  const [open, setOpen] = useState(false);

  // Cmd+K / Ctrl+K shortcut
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen(true);
      }
    };
    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  }, []);

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground border border-border rounded-lg hover:bg-muted transition-colors"
      >
        <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
        </svg>
        Search
        <kbd className="ml-auto text-xs opacity-60">K</kbd>
      </button>

      {open && <SearchModal onClose={() => setOpen(false)} />}
    </>
  );
}

Add it to your layout:

// app/layout.tsx
import { SearchButton } from "@/components/search/SearchButton";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header className="flex items-center justify-between px-6 py-4 border-b">
          <a href="/">My App</a>
          <SearchButton />
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

Step 6: Keep the Index Current

For content that changes frequently, trigger reindexing when content is updated. With a webhook:

// app/api/webhooks/content/route.ts
import { NextRequest, NextResponse } from "next/server";
import { AACSearchClient } from "@AACSearch/client";

const client = new AACSearchClient({
	apiUrl: process.env.AACSEARCH_API_URL!,
	apiKey: process.env.AACSEARCH_INGEST_KEY!,
});

export async function POST(request: NextRequest) {
	const { event, article } = await request.json();

	if (event === "article.published" || event === "article.updated") {
		await client.documents("articles").upsert({
			id: article.slug,
			title: article.title,
			content: article.content,
			excerpt: article.excerpt,
			category: article.category,
			tags: article.tags,
			published_at: Math.floor(Date.now() / 1000),
		});
	}

	if (event === "article.deleted") {
		await client.documents("articles").delete(article.slug);
	}

	return NextResponse.json({ ok: true });
}

What You Get

After this setup:

  • < 50ms search responses regardless of index size
  • Typo tolerance — "javascrpt" finds JavaScript results
  • Prefix matching — results appear after 2–3 characters
  • Keyboard navigation — fully accessible without a mouse
  • Cmd+K trigger — the standard power-user shortcut
  • Zero server load — search runs against the AACSearch API, not your Next.js server

The search modal component is ~120 lines of plain React — no additional UI library dependencies required.

Production Checklist

  • [ ] Search key is read-only (never the ingest key)
  • [ ] Route Handler validates and sanitizes the q parameter
  • [ ] dangerouslySetInnerHTML only renders AACSearch highlight snippets (not user input)
  • [ ] Debounce is ≥ 150ms (prevents rate limiting)
  • [ ] Zero-result queries logged to analytics
  • [ ] Index updated on content publish/update/delete events
  • [ ] Env vars set in production (not just .env.local)