Syncing Sanity CMS to Algolia Search in Next.js
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
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 your project settings at sanity.io/manage (API → Webhooks), or with the Sanity CLI, create a webhook targeting your route handler URL. Add a secret so the handler can verify the signature — 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 signature with @sanity/webhook, decides whether to upsert or delete, and calls the Algolia JavaScript client. The code below uses the v5 client (npm install algoliasearch @sanity/webhook), which removed v4's initIndex() — methods now live on the client and take an indexName.
// app/api/algolia-sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
import { algoliasearch } from 'algoliasearch'
const client = algoliasearch(
process.env.ALGOLIA_APP_ID!,
process.env.ALGOLIA_ADMIN_API_KEY!
)
const indexName = process.env.ALGOLIA_INDEX_NAME!
export async function POST(req: NextRequest) {
const rawBody = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ''
if (!(await isValidSignature(rawBody, signature, process.env.SANITY_WEBHOOK_SECRET!))) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Sanity sends these headers on every webhook request
const operation = req.headers.get('sanity-operation') // 'create' | 'update' | 'delete'
const documentId = req.headers.get('sanity-document-id')
if (!documentId) {
return NextResponse.json({ error: 'Missing document id' }, { status: 400 })
}
if (operation === 'delete') {
await client.deleteObject({ indexName, objectID: documentId })
return NextResponse.json({ deleted: documentId })
}
// The body is the result of your webhook projection
const doc = JSON.parse(rawBody) as {
_id: string
title?: string
slug?: { current: string }
excerpt?: string
publishedAt?: string
categories?: string[]
}
await client.saveObject({
indexName,
body: {
objectID: documentId,
title: doc.title ?? '',
slug: doc.slug?.current ?? '',
excerpt: doc.excerpt ?? '',
publishedAt: doc.publishedAt ?? null,
categories: doc.categories ?? [],
},
})
return NextResponse.json({ synced: documentId })
}A few things worth noting here:
req.text()beforeJSON.parseis necessary because you need the raw body for signature verification. Sanity signs payloads Stripe-style (timestamp plus signature in thesanity-webhook-signatureheader), so use@sanity/webhookrather than a plain HMAC of the body.- The webhook body is your projection, not a wrapper object. The operation and document ID arrive as the
sanity-operationandsanity-document-idheaders. Unpublishing a document also fires adelete. 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-instantsearchThe 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 { liteClient as 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 a failed delivery (5xx or 429) only twice, 30 seconds apart, per its webhook docs. Three failure modes worth planning for:
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 client.browseObjects, and deletes orphans. Schedule it as a Vercel cron job (vercel.json crons field).
2. Draft content leaking into the index. By default, Sanity webhooks ignore documents in the drafts. and versions. namespaces. Leave the drafts setting off for this webhook — if someone enables it, draft edits (nearly every keystroke) will start upserting unpublished content into the index.
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 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 algolia.saveObjects({ indexName: process.env.ALGOLIA_INDEX_NAME!, objects: 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.
Frequently asked questions
01How do I keep an Algolia index in sync with Sanity CMS content?
Wire a Sanity webhook (filtered to your document type, triggered on create/update/delete) to a Next.js route handler that verifies the webhook signature (e.g. with `@sanity/webhook`) and calls `client.saveObject()` or `client.deleteObject()` from the Algolia JavaScript client (v5). Webhook delivery isn't guaranteed, so pair it with a nightly reconciliation script that diffs published Sanity document IDs against Algolia `objectID`s and cleans up orphans.
02Why does deleted Sanity content still show up in Algolia search results?
Usually a delete webhook failed and Sanity's retries (it retries a failed delivery only twice, 30 seconds apart) were exhausted. Run a scheduled reconciliation job (a Vercel cron calling `client.browseObjects` against a fresh GROQ fetch of all published IDs) to catch and delete these orphans, rather than relying on webhook delivery alone for correctness.