How to Sync Sanity Content to Algolia for Search
The failure mode is familiar: your Sanity content model is clean, your GROQ queries are fast, and then someone asks for typo-tolerant, faceted, sub-50ms search across ten thousand documents.
The failure mode is familiar: your Sanity content model is clean, your GROQ queries are fast, and then someone asks for typo-tolerant, faceted, sub-50ms search across ten thousand documents. You try to bolt search onto your query layer, discover Content Lake is a content store and not a search engine, and end up with a fragile cron job that reindexes everything at 3am and still serves stale results the moment an editor hits publish. Search drifts out of sync with content, and nobody trusts it.
Sanity is a headless content platform, the Content Operating System that keeps your structured content queryable and real-time while you hand the search problem to a purpose-built engine like Algolia. The pattern that works is not "replace GROQ with Algolia," it is "let each system do its job": Content Lake owns the canonical, structured, versioned content, and Algolia owns the inverted index, ranking, and typo tolerance.
This guide walks the sync architecture end to end: shaping documents for an index, choosing between webhook-driven incremental updates and Functions, handling drafts and deletions, and keeping the two systems consistent without a nightly rebuild. The goal is a search index that updates within seconds of publish and never lies about what is live.
Why search belongs outside your content store
Start with the architectural honesty most teams skip: a content store and a search engine solve different problems, and pretending one is the other is where sync pipelines rot. Content Lake, the queryable store behind Sanity, is built for schema-aware structured queries, references, and real-time reads. GROQ can filter, project, and join across your dataset in a single round trip, and for a lot of retrieval that is exactly right. What GROQ is not built for is the inverted-index workload: typo tolerance, tokenization across languages, synonym expansion, business-rule ranking, and faceted counts over large result sets returned in single-digit milliseconds. That is Algolia's job.
The wrong instinct is to force search into the query layer, matching document text with GROQ's match() operator and hoping it scales. It works in a demo and falls over at volume, because match() is prefix and token matching, not a ranked, typo-tolerant relevance engine with configurable tie-breaking. The right instinct is a projection boundary: Content Lake stays the single source of truth for canonical content, and Algolia holds a denormalized, search-optimized copy of just the fields users query and display.
That separation is also a governance win. Your editors keep working in Sanity Studio against a versioned, referenced content model, and search is a downstream derivative you can rebuild from source at any time. When search breaks, your content is untouched; when your model changes, you reshape the projection, not the truth. The engineering question that remains is not whether to sync, it is how to keep the derivative fresh without a nightly full rebuild, and that is where the pattern lives or dies.
Shaping the Algolia record with a GROQ projection
An Algolia record is flat, denormalized, and contains only what search needs. Your Sanity document is nested, referenced, and contains everything the editorial model needs. The bridge between them is a GROQ projection, and getting it right up front saves you from reindexing churn later.
Write the projection to resolve references and collapse structure into the exact shape Algolia will store. GROQ's dereference operator (`->`) pulls a referenced author or category into the record inline, so you index `author->name` rather than a dangling reference id. Projections let you rename, compute, and filter fields in the same query, so you ship `title`, `slug.current`, `categories[]->title`, and a plain-text excerpt without a second call. Portable Text needs special handling: search wants a searchable string, not a block array, so flatten your rich text to plain text (via a helper that walks the blocks) before it becomes a record attribute, while keeping the structured Portable Text canonical in Content Lake.
Set the `objectID` on every record to the Sanity document `_id`. This is the single most important decision in the whole pipeline, because it makes every downstream operation idempotent: an update and an insert become the same `saveObjects` call, and a delete is just `deleteObject(_id)`. Decide early how you handle drafts. Draft documents in Sanity carry a `drafts.` prefix on their `_id`, so your projection should filter them out (`!(_id in path('drafts.**'))`) unless you are deliberately building a preview index. Keep the projection in version control next to your schema; when TypeGen regenerates types from your `defineType` schemas, a drifting projection surfaces as a type error instead of a silent index of the wrong shape.
Incremental sync with webhooks
The nightly full-reindex is the anti-pattern that motivated this article, so the target is event-driven: when a document changes in Content Lake, push exactly that document's record to Algolia within seconds. Sanity's GROQ-powered webhooks are the mechanism. You define a webhook with a projection and a filter, so instead of receiving a raw mutation you receive a payload already shaped like an Algolia record, only for the document types you care about.
The filter is where you encode intent. Scope the webhook to `_type == "post"` (or whichever types are searchable), and use the projection to resolve references and flatten Portable Text the same way your bulk projection does, so the two code paths produce identical records. The webhook fires on create, update, and delete, so your receiving endpoint needs to branch: on a delete or an unpublish it calls `deleteObject`, otherwise it calls `saveObject`. Because `objectID` equals `_id`, both branches are idempotent and safe to retry.
The consistency subtlety is drafts and publishing. In Sanity, publishing is a mutation that promotes the draft to the published `_id`, so a well-scoped webhook filter (excluding the `drafts.` path) fires on the published document and indexes the live version, not the in-progress edit. Guard the endpoint with the webhook's signing secret so nothing but Sanity can write to your index, and make the handler tolerant of at-least-once delivery. This is the whole payoff of the pattern: an editor hits publish in the Studio, the webhook fires, one record updates, and search reflects reality in seconds without touching the other 9,999 documents.
Serverless sync with Functions and the App SDK
Webhooks calling an external endpoint work, but they push the sync logic into infrastructure you host, monitor, and secure separately from your content platform. For teams who want the sync to live closer to the content, Functions run serverless logic in response to content events without you standing up and maintaining a separate service. The projection, the flatten-Portable-Text helper, and the Algolia `saveObjects` call move into a Function that Sanity triggers, so the indexing code ships and versions alongside your schema and Studio config rather than drifting in a separate repo.
This is where the difference between a CMS that bolts things on and a platform that operates content end to end shows up in practice. Legacy headless CMSes typically stop at delivering content over an API and leave every downstream automation, search sync, translation, moderation, to glue code you own entirely. Because Sanity treats automation as a first-class pillar, the App SDK and Functions let you keep enrichment and fan-out logic inside the same governed system that holds the content, with the same access controls and audit trail.
A common composition: a Function normalizes and flattens the changed document, writes the search record to Algolia, and optionally writes a derived field back to Content Lake (for example a `lastIndexedAt` timestamp) so editors can see indexing status in the Studio. You still keep a bulk-reindex script for the cold-start and schema-change cases, but the steady state is event-driven and observable, and the reindex script and the Function share one projection so they can never disagree about record shape.
Keeping the two systems consistent
Every dual-write architecture has the same enemy: divergence. Content Lake says one thing, Algolia says another, and users lose trust in search. The pattern survives contact with production only if you design for the failure cases up front rather than discovering them as stale-result bug reports.
Handle deletes and unpublishes explicitly. A deleted Sanity document must trigger a `deleteObject(_id)`, and an unpublish (which removes the published document while a draft may remain) must also remove the record so search never surfaces content that is no longer live. Make every write idempotent by keying on `objectID == _id`, so a retried webhook or a replayed event converges to the correct state instead of creating duplicates. Assume at-least-once delivery and build the handler accordingly.
For drift detection and recovery, keep the bulk reindex as a reconciliation tool, not a schedule. A weekly or on-demand full rebuild from the same GROQ projection is your ground truth, and comparing the reindexed record count against Algolia's index size flags orphaned records. When you change your schema or projection, run the bulk job once so the whole index adopts the new shape, then let webhooks or Functions carry incremental updates again. The discipline that makes all of this tractable is having exactly one projection definition shared by the bulk path and the event path; two projections that are supposed to match but live in different files are a divergence bug waiting to happen.
Reference architecture and rollout order
Assemble the pieces in an order that lets you validate each boundary before adding the next, because debugging a full event-driven pipeline all at once is how weekends disappear.
First, write and freeze the GROQ projection that produces an Algolia record, and run it as a one-off script over your dataset to bulk-index. Verify the record shape, the flattened Portable Text, the resolved references, and confirm `objectID` equals `_id` on every record. Search should work end to end against a static snapshot before anything is event-driven. Second, configure Algolia's index settings deliberately: searchable attributes in priority order, attributes for faceting, custom ranking for business rules like recency or popularity, and synonyms. This is search-engine work Content Lake was never meant to do, and it is the reason you are syncing out in the first place.
Third, add the incremental layer, either a GROQ-projection webhook to an endpoint or a Function, reusing the exact projection from step one. Handle create, update, delete, and unpublish, sign or authenticate the writes, and make the handler idempotent. Fourth, add observability: log every index write, optionally stamp a `lastIndexedAt` back into Content Lake so editors see indexing state in the Studio, and schedule the bulk reindex as an on-demand reconciliation job rather than a nightly crutch. The result is the architecture this guide argued for from the start: Content Lake as the versioned single source of truth, Algolia as the search-optimized derivative, and a fast, idempotent event path keeping them in step so search reflects what is actually live within seconds of publish.
Approaches to syncing content into Algolia
| Feature | Sanity | Contentful | Strapi | WordPress headless |
|---|---|---|---|---|
| Shaping the search record | GROQ projection resolves references with `->`, renames and computes fields, and filters drafts in one query, so the record shape is defined declaratively. | Fetch entries via the Content Delivery API, then resolve linked entries and reshape in application code before indexing. | REST or GraphQL fetch with populate for relations, then flatten to a record in your own transform layer. | Query via WPGraphQL or REST, then reassemble post, taxonomy, and meta fields in code to build a flat record. |
| Event-driven incremental sync | GROQ-powered webhooks send an already-shaped, filtered payload on create, update, and delete, so the endpoint indexes one record at a time. | Webhooks fire on entry publish and unpublish with the raw entry payload; you shape and dereference downstream. | Lifecycle hooks or webhooks in the app fire on content events; payload shaping is your code. | Requires a sync plugin or custom post-save hooks; raw post data is reshaped in PHP or a downstream service. |
| Sync logic hosting | Functions and the App SDK run the indexing logic serverless, versioned alongside schema and Studio config, with the same access controls. | Sync logic runs in your own serverless functions or app tier, separate from the CMS. | Logic lives in the self-hosted app or an external service you deploy and operate. | Logic lives in the WordPress plugin runtime or an external worker you maintain. |
| Draft and unpublish handling | Drafts carry a `drafts.` id prefix; a path filter excludes them, and publish or unpublish mutations trigger the right saveObject or deleteObject. | Separate draft and published states via the Content Management vs Delivery APIs; you route each to the correct index action. | Draft and publish states depend on the content-type config; routing to index actions is custom code. | Draft, pending, and published statuses map to index actions through your plugin or hook logic. |
| Idempotency of writes | objectID set to the document `_id` makes update and insert one saveObject call and delete one deleteObject call, safe under at-least-once delivery. | Achievable by mapping objectID to the entry id; you enforce it in your transform code. | Achievable by keying objectID to the entry id in your own indexing layer. | Achievable by keying objectID to the post id, implemented in the plugin or worker. |
| Reconciliation and rebuild | One shared GROQ projection powers both the bulk reindex and the event path, so the ground-truth rebuild can never disagree with incremental updates. | Bulk reindex and incremental sync are typically separate code paths you keep aligned yourself. | Full reindex script and incremental hooks are separate implementations to keep in sync. | Full rebuild and incremental hooks are separate, and plugin and code paths can drift. |