Adding Algolia search to a Sanity CMS + Next.js app sounds straightforward until you hit the first out-of-sync index and a user searches for content that was deleted three hours ago. This post covers the full setup: forwarding Sanity webhook events to Algolia, building the search UI with InstantSearch React, and the edge cases worth thinking about before you go live.
Why Algolia alongside Sanity CMS and Next.js
Sanity's GROQ is great for page queries but it is not a search engine. Full-text search, faceting, typo tolerance, and relevance tuning are not its job. Algolia fills that gap cleanly, and the combination of Sanity webhooks + a Next.js route handler gives you a sync path that needs zero polling and adds no build-time overhead.
The architecture I use:
- Editor publishes or unpublishes a document in Sanity Studio.
- Sanity fires a webhook to a Next.js route handler.
- The route handler upserts or deletes the record in Algolia.
- The frontend queries Algolia directly via InstantSearch.
Setting up the Sanity webhook
In sanity.config.ts or the Sanity dashboard (API → Webhooks), create a webhook targeting your route handler URL. Use HMAC verification — the same pattern as ISR revalidation webhooks.
Set the projection in the webhook payload so you only ship the fields Algolia needs. Sending the whole document wastes bandwidth and pollutes the index.
Dashboard projection example:
{
_id,
_type,
title,
slug,
excerpt,
publishedAt,
"categories": categories[]->title
}
Set the filter to _type == "post" (or whatever your document type is) and trigger on create, update, and delete operations.
The Next.js route handler
Create app/api/algolia-sync/route.ts. This handler verifies the HMAC signature, decides whether to upsert or delete, and calls the Algolia Node client.
// app/api/algolia-sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createHmac, timingSafeEqual } from 'crypto'
import algoliasearch from 'algoliasearch'
const client = algoliasearch(
process.env.ALGOLIA_APP_ID!,
process.env.ALGOLIA_ADMIN_API_KEY!
)
const index = client.initIndex(process.env.ALGOLIA_INDEX_NAME!)
function verifySignature(body: string, signature: string): boolean {
const secret = process.env.SANITY_WEBHOOK_SECRET!
const expected = createHmac('sha256', secret).update(body).digest('hex')
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
} catch {
return false
}
}
export async function POST(req: NextRequest) {
const rawBody = await req.text()
const signature = req.headers.get('sanity-webhook-signature') ?? ''
if (!verifySignature(rawBody, signature)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = JSON.parse(rawBody) as {
_id: string
_type: string
operation: 'create' | 'update' | 'delete'
result?: {
_id: string
title?: string
slug?: { current: string }
excerpt?: string
publishedAt?: string
categories?: string[]
}
}
const { _id, operation, result } = payload
if (operation === 'delete' || !result) {
await index.deleteObject(_id)
return NextResponse.json({ deleted: _id })
}
await index.saveObject({
objectID: _id,
title: result.title ?? '',
slug: result.slug?.current ?? '',
excerpt: result.excerpt ?? '',
publishedAt: result.publishedAt ?? null,
categories: result.categories ?? [],
})
return NextResponse.json({ synced: _id })
}
A few things worth noting here:
-
req.text()beforeJSON.parseis necessary because you need the raw bytes for HMAC verification. - The Sanity webhook
operationfield is'delete'even if the document never had a published version — handle the missingresultguard explicitly. -
saveObjectwith an explicitobjectIDis idempotent. Running it twice on the same document does not create a duplicate.
Building the search UI with InstantSearch React
Install the packages:
npm install algoliasearch react-instantsearch
The react-instantsearch package (v7+) ships hooks and headless primitives that work with any styling approach. I wrap the whole thing in a client component because InstantSearch is inherently interactive.
// components/search/PostSearch.tsx
'use client'
import algoliasearch from 'algoliasearch/lite'
import {
InstantSearch,
SearchBox,
Hits,
Highlight,
RefinementList,
Configure,
} from 'react-instantsearch'
import Link from 'next/link'
const searchClient = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY!
)
type Hit = {
objectID: string
title: string
slug: string
excerpt: string
categories: string[]
}
function PostHit({ hit }: { hit: Hit }) {
return (
<article className="border-b py-4">
<Link href={`/blog/${hit.slug}`}>
<h3 className="font-semibold text-lg">
<Highlight attribute="title" hit={hit} />
</h3>
</Link>
<p className="text-sm text-gray-600">
<Highlight attribute="excerpt" hit={hit} />
</p>
</article>
)
}
export function PostSearch() {
return (
<InstantSearch
searchClient={searchClient}
indexName={process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME!}
future={{ preserveSharedStateOnUnmount: true }}
>
<Configure hitsPerPage={12} />
<SearchBox
placeholder="Search posts…"
classNames={{ input: 'w-full border rounded px-3 py-2' }}
/>
<RefinementList attribute="categories" className="mt-4" />
<Hits<Hit> hitComponent={PostHit} className="mt-6" />
</InstantSearch>
)
}
Only the search API key goes into NEXT_PUBLIC_*. The admin key lives server-side only. Algolia's search key is safe to expose — it is scoped to read-only operations on the index.
Handling out-of-sync edge cases
Webhook delivery is not guaranteed. Sanity retries failed deliveries, but if your route handler returns a 5xx, the retry window is finite. Three failure modes I have actually hit:
1. Deleted document still appearing in results. This happens when a delete webhook failed silently. Fix: run a nightly reconciliation script that fetches all published document IDs from Sanity via GROQ, diffs them against all objectIDs in Algolia using index.browseObjects, and deletes orphans. Schedule it as a Vercel cron job (vercel.json crons field).
2. Draft content leaking into the index. Sanity fires webhooks for drafts if you are not careful. In the webhook filter, add !(_id in path("drafts.**")) to exclude draft documents entirely.
3. Renamed slug not updating the search result link. If you store the slug in Algolia and an editor changes it, the old slug in the index becomes a dead link. The webhook covers this — an update operation upserts the full object — but make sure the webhook projection includes slug and that you always call saveObject with the full record, not a partial update.
Initial index population
When you first wire this up, the index is empty. Write a one-off script to backfill:
// scripts/backfill-algolia.ts
import { createClient } from '@sanity/client'
import algoliasearch from 'algoliasearch'
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false,
token: process.env.SANITY_READ_TOKEN,
})
const algolia = algoliasearch(
process.env.ALGOLIA_APP_ID!,
process.env.ALGOLIA_ADMIN_API_KEY!
)
const index = algolia.initIndex(process.env.ALGOLIA_INDEX_NAME!)
const posts = await sanity.fetch(`
*[_type == "post" && !(_id in path("drafts.**")) && defined(slug.current)] {
_id, title, "slug": slug.current, excerpt, publishedAt,
"categories": categories[]->title
}
`)
const records = posts.map((p: any) => ({ ...p, objectID: p._id }))
await index.saveObjects(records)
console.log(`Indexed ${records.length} posts`)
Run it once with npx tsx scripts/backfill-algolia.ts. After that, webhooks keep things in sync.
What this setup does not cover
Algolia relevance tuning (custom ranking, synonyms, query rules) is out of scope here — that lives in the Algolia dashboard and deserves its own post. Same for analytics-driven ranking using Algolia Insights events. Get the sync working correctly first; tuning relevance on a stale index is wasted effort.
Top comments (0)