How to Add Instant Search to a Next.js App (Complete Guide)
Alex Chibilyaev
5/3/2026
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 “{query}”
</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
qparameter - [ ]
dangerouslySetInnerHTMLonly 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)