How I sync Sanity CMS to Algolia search in a Next.js app

Jun 12, 2026 · 6 min read

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:

  1. Editor publishes or unpublishes a document in Sanity Studio.
  2. Sanity fires a webhook to a Next.js route handler.
  3. The route handler upserts or deletes the record in Algolia.
  4. 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() before JSON.parse is necessary because you need the raw bytes for HMAC verification.
  • The Sanity webhook operation field is 'delete' even if the document never had a published version — handle the missing result guard explicitly.
  • saveObject with an explicit objectID is 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.

Related posts

All posts →