How to Add Search to a Static Next.js Site
Adding search to a static site sounds simple until you remember there's no server. You can't just hit an endpoint when someone types into a box. The whole point of a static export is that it's just files.
What actually works: ship a JSON index as a static file, load it in the browser, and run every query locally against data that's already in memory. No round-trips, no infrastructure, results show up as you type. It feels instant because it is.
This is how I set it up using Fuse.js, including a few gotchas I ran into (Safari link handling, mobile zoom, portal stacking contexts). I also cover when Fuse.js isn't the right tool and what to use instead.
Why client-side search works here
With output: 'export', your Next.js build produces a folder of HTML files. There's no server process running, nothing intercepting requests. So when someone types into a search box, you have two real options: run the search in the browser, or send it to an external service.
The external service route (Algolia, Meilisearch, etc.) works fine but you're paying for it in money, latency, or both. Every keystroke becomes a network request, and you've added a third-party dependency to something that was otherwise self-contained.
Client-side search sidesteps all of that. You build a JSON index of your content at build time, ship it as a static file, and the browser downloads it once on first search. After that every query runs locally against data that's already in memory. For a blog or docs site with a few hundred pages, the index is typically under 100KB and search feels instant.
For most static sites that's the obvious choice. There's no infrastructure to manage, it works offline, and the index only changes when you redeploy.
Setting up Fuse.js
Fuse.js is a zero-dependency fuzzy search library that runs entirely in the browser. "Fuzzy" means it handles typos and partial matches, so "optmize" still finds "optimize". It weighs about 5KB gzipped.
npm install fuse.js
Building the search dataset
Before writing any UI, you need a flat array of searchable items. The shape doesn't matter much as long as every item has a title, a description, and a URL. Pull from wherever your content is defined, whether that's a TypeScript array, imported JSON, or a generated file:
// lib/search-data.ts
export type SearchItem = {
title: string
description: string
slug: string
type: string
url: string
}
// Import your content arrays, whatever they look like in your project
import { posts } from './posts'
import { tools } from './tools'
export const searchData: SearchItem[] = [
...posts.map((post) => ({
title: post.title,
description: post.description,
slug: post.slug,
type: 'post',
url: `/posts/${post.slug}/`,
})),
...tools.map((tool) => ({
title: tool.title,
description: tool.description,
slug: tool.slug,
type: 'tool',
url: `/tools/${tool.slug}/`,
})),
]
The type field is just a label shown in the results UI so users can tell posts from tools at a glance. Add as many content types as you need.
The search component
The component manages two pieces of state: the query string and whether the dialog is open. Fuse.js is initialized once with useMemo so it doesn't rebuild the index on every render.
'use client'
import { useState, useMemo, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { usePathname } from 'next/navigation'
import Fuse from 'fuse.js'
import { Search, X } from 'lucide-react'
import { searchData } from '@/lib/search-data'
export default function SearchDialog() {
const [query, setQuery] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const pathname = usePathname()
// Close on navigation
useEffect(() => {
setIsOpen(false)
setQuery('')
}, [pathname])
const fuse = useMemo(
() =>
new Fuse(searchData, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'description', weight: 1 },
],
threshold: 0.3,
includeScore: true,
minMatchCharLength: 2,
}),
[]
)
const results = useMemo(() => {
if (!query.trim()) return []
return fuse.search(query).slice(0, 8)
}, [query, fuse])
useEffect(() => {
setSelectedIndex(-1)
}, [results])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
setIsOpen(false)
setQuery('')
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
useEffect(() => {
if (isOpen && inputRef.current) inputRef.current.focus()
}, [isOpen])
const close = () => {
setIsOpen(false)
setQuery('')
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((i) => {
const next = Math.min(i + 1, results.length - 1)
listRef.current?.children[next]?.scrollIntoView({ block: 'nearest' })
return next
})
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((i) => {
const prev = Math.max(i - 1, 0)
listRef.current?.children[prev]?.scrollIntoView({ block: 'nearest' })
return prev
})
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault()
const item = results[selectedIndex]?.item
if (item) window.location.href = item.url
}
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm
text-muted-foreground border rounded-md hover:bg-accent transition-colors"
aria-label="Search"
>
<Search className="w-4 h-4" />
<span>Search...</span>
</button>
)
}
return createPortal(
<div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) close() }}
>
<div className="mx-auto mt-[20vh] max-w-lg bg-background border rounded-lg shadow-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 border-b">
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search..."
className="flex-1 py-3 bg-transparent outline-none text-base sm:text-sm"
/>
{query && (
<button onClick={() => setQuery('')} aria-label="Clear">
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{results.length > 0 && (
<ul ref={listRef} className="max-h-80 overflow-y-auto py-2">
{results.map(({ item }, index) => (
<li key={item.url}>
<a
href={item.url}
className={`flex flex-col gap-1 px-4 py-2 transition-colors ${
index === selectedIndex ? 'bg-accent' : 'hover:bg-accent'
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{item.type}
</span>
<span className="text-sm font-medium">{item.title}</span>
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{item.description}
</span>
</a>
</li>
))}
</ul>
)}
{query && results.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No results for "{query}"
</div>
)}
</div>
</div>,
document.body
)
}
Some of these choices need a bit of explanation.
The overlay uses createPortal to mount directly on document.body rather than inside the header component. This matters because any ancestor with backdrop-filter creates a new stacking context, which traps position: fixed children inside it. Without the portal, the backdrop only covers the header, not the full screen.
For closing on navigation, I watch usePathname() and reset state in an effect. This way the dialog always cleans up regardless of how navigation happened, including keyboard Enter on a selected result. Relying on click handlers alone misses too many cases.
The handleInputKeyDown handler tracks a selectedIndex and calls scrollIntoView on each move so the highlighted item doesn't disappear below the fold of the scrollable list. Without scrollIntoView, keyboard navigation works but feels broken once you get past the first few results.
The text-base sm:text-sm on the input is a Safari-specific workaround. iOS auto-zooms any input with a font size below 16px, which is jarring on a search dialog. 16px on mobile, 14px on desktop is the fix.
Add it to your header
Drop the component into your navigation. It renders a trigger button when closed and mounts the full dialog via a portal when open, so it fits anywhere in the tree.
import SearchDialog from '@/components/search-dialog'
export default function Header() {
return (
<nav className="flex items-center justify-between px-6 py-3 border-b">
<a href="/" className="font-bold">My Site</a>
<SearchDialog />
</nav>
)
}
Tuning Fuse.js
Most of the time you won't need to touch the defaults, but two settings come up regularly.
threshold is the one to adjust first if results feel off. It goes from 0 (exact matches only) to 1 (match almost anything). The default is 0.6, which is pretty loose. I dropped it to 0.3 and results got noticeably more relevant. If you're seeing good matches get filtered out, bump it up a bit.
keys with weights is how you control ranking. Title matches feel more relevant than description matches to most users, so weighting title at 2 and description at 1 reflects that. You can add more fields, like tags or category, and weight them however makes sense for your content.
minMatchCharLength is a minor one but worth setting to 2. Without it, typing a single letter returns half your dataset.
When Fuse.js hits its limits
The whole dataset loads into memory on first search. For a couple hundred pages that's nothing, maybe 50KB of JSON. But it doesn't scale forever.
Once you're past roughly 500 items, you'll start to feel it, not in search speed but in the time it takes to build the index client-side on every page load. The fix is to pre-build the index at build time and load it as a static file instead:
// scripts/build-search-index.mjs
import Fuse from 'fuse.js'
import { writeFileSync } from 'fs'
import { searchData } from '../lib/search-data.js'
const index = Fuse.createIndex(['title', 'description'], searchData)
writeFileSync('public/search-index.json', JSON.stringify(index.toJSON()))
writeFileSync('public/search-data.json', JSON.stringify(searchData))
Then load both files at runtime instead of building the index in the browser:
const data = await fetch('/search-data.json').then(r => r.json())
const indexData = await fetch('/search-index.json').then(r => r.json())
const index = Fuse.parseIndex(indexData)
const fuse = new Fuse(data, options, index)
Past 2,000 items, Fuse.js is the wrong tool. Look at Pagefind or a hosted service instead.
Pagefind
Fuse.js only searches the fields you explicitly put in your dataset. If you want search to reach inside the body of your articles, not just titles and descriptions, you need something that indexes the actual rendered HTML.
Pagefind does exactly that. It's a Rust binary that runs after your Next.js build, crawls the out/ directory, and generates a compressed index from the content of every page. You don't maintain a separate data file.
npm install --save-dev pagefind
{
"scripts": {
"build": "next build",
"postbuild": "npx pagefind --site out"
}
}
The downside is the UI. Pagefind ships its own search widget, and while it's customizable, you're working within its constraints rather than building exactly what you want. If the UI matters a lot to you, that's worth weighing.
Algolia DocSearch
If your site is public documentation or a technical blog, Algolia offers DocSearch for free. They crawl your site, host the index on their infrastructure, and give you a drop-in React component.
npm install @docsearch/react
import { DocSearch } from '@docsearch/react'
import '@docsearch/css'
export default function Search() {
return (
<DocSearch
appId="YOUR_APP_ID"
indexName="YOUR_INDEX_NAME"
apiKey="YOUR_SEARCH_API_KEY"
/>
)
}
You apply, they approve your site, and after that you don't touch the search infrastructure again. The catch is you're locked into their widget and their ranking. For most docs sites that's fine. For a blog where you care about how results look, less so.
Quick comparison
| Fuse.js | Pagefind | Algolia DocSearch | |
|---|---|---|---|
| Full-text search | No | Yes | Yes |
| Fuzzy matching | Yes | Yes | Yes |
| Bundle size | ~5KB | ~10KB (on demand) | ~25KB |
| Self-hosted | Yes | Yes | No |
| Build step | No | Yes | No |
| Custom UI | Full control | Partial | Partial |
Where to start
If your site has under a few hundred pages, start with Fuse.js. The whole thing fits in a couple of files, there's nothing to deploy or configure beyond what's already here, and you own the UI completely. That's hard to beat for the use case it's designed for.
When the content grows or you need results from inside article bodies, Pagefind is the natural next step. And if you'd rather not think about search infrastructure at all and your site qualifies, DocSearch is worth applying for.