Personalised Recommendations From a Single Content API
Most personalization stacks fail the same way: the recommendation engine and the content it recommends live in different systems.
Most personalization stacks fail the same way: the recommendation engine and the content it recommends live in different systems. Product data sits in one database, editorial content in a CMS, embeddings in a separate vector store, and user signals in a warehouse. By the time you stitch them together at request time, you are making four network hops, reconciling three ID schemes, and shipping a "recommended for you" rail that surfaces an unpublished draft or a product that went out of stock an hour ago.
The stakes are not academic. Stale or wrong recommendations erode the exact trust that personalization is supposed to build, and every extra system in the retrieval path is another place latency and inconsistency creep in. The usual fix, bolting a recommendation microservice onto a headless CMS, just moves the seams around.
This article reframes the problem: when your content, its embeddings, and its query layer sit in one queryable store, personalized recommendations become a single API call with a filter, not a distributed-systems project. In Sanity, GROQ can blend semantic similarity, structured filters, and publish state in one round trip against the Content Lake, which is what collapses the stack.
Why do recommendation stacks drift out of sync with the content they recommend?
A recommendation stack drifts out of sync because the model that ranks items and the store that holds those items are two different systems with two different clocks. A typical setup exports content nightly to a warehouse, computes embeddings in a batch job, loads vectors into a dedicated index, and serves ranked IDs from a microservice that then re-fetches the actual content from the CMS. Every one of those boundaries is a chance for the two sides to disagree.
The concrete failure looks like this. An editor unpublishes a promotion at 2pm. The content API immediately stops serving it, but the vector index was built at midnight and still ranks it near the top of a user's feed. The recommendation service returns that ID, the frontend fetches it, gets a 404 or an empty document, and either crashes the rail or renders a hole. Multiply that by seasonal products, embargoed articles, and region-locked content, and the personalization surface becomes the least trustworthy part of the site.
The root cause is architectural, not a tuning problem. As long as ranking signals and content state live apart, you are reconciling them at request time under a latency budget, which is exactly when you have the least room to be careful. The fix is to stop treating the vector index as a separate database. When embeddings are stored as fields on the same documents they describe, publish state and similarity score are queryable in the same expression, so an unpublished document simply cannot be returned. That is the difference between synchronizing two systems and never having two systems to synchronize.
What does 'personalized recommendations from a single content API' actually mean?
Personalized recommendations from a single content API means the retrieval, ranking, filtering, and content fetch for a recommendation rail all resolve in one query against one store, rather than being fanned out across a vector database, a CMS, and a rules engine. You send a request describing the user and the context; you get back fully-formed content documents, already filtered to what is publishable and relevant, in the shape your frontend renders.
In Sanity this is a GROQ query against the Content Lake. GROQ lets you ask for exactly the shape you need in one round trip, including projections, references, and filters, so a recommendation query can compute a similarity score, apply structured constraints like category, locale, and stock status, sort by a blended relevance ranking, and project only the fields the card component needs. The text::semanticSimilarity() function scores documents against a query vector, match() handles keyword filters, and the -> operator dereferences related products or authors inline, all in the same expression.
The strategic consequence is that personalization stops being a separate service you operate and becomes a query pattern you write. You are not maintaining a sync pipeline between your content and your recommendations, because there is nothing to sync: the embedding is a field on the document, the publish state is a field on the document, and the query reads both at once. A definition worth keeping: a single-content-API recommendation is one where the same read that ranks candidates also enforces content governance and returns render-ready data, with no second hop to resolve IDs into content.
How do you blend semantic similarity with structured business rules in one query?
You blend semantic similarity with structured business rules by scoring on the vector and filtering on the fields in the same query, so relevance and eligibility are evaluated together rather than in sequence across two systems. Pure vector search will happily rank a discontinued product or an article that is embargoed in the user's region; pure rules-based filtering has no notion of thematic closeness. Real recommendations need both, applied atomically.
With GROQ, the pattern is to constrain the candidate set with boolean filters first, then order the survivors by semantic score. A query can say: give me documents of type product, in the visitor's locale, currently in stock, not in an excluded category, and among those, rank by text::semanticSimilarity() against the embedding of what the user just viewed. Because match() and the comparison operators run in the same expression as the similarity function, an ineligible item never reaches the ranking stage, and an item that ranks well but fails a business rule never reaches the response.
This matters because the hard part of personalization is rarely the vector math; it is the long tail of rules that keep the output honest. Exclude items already purchased. Boost editorially featured content with score() weighting. Respect content that is scheduled but not yet live. When those rules live in the same query as the ranking, a change to a rule is a change to one expression, not a coordinated deploy across a recommender service and a content API. The counter-example, a vector store that only knows IDs and scores, forces every business rule back into application code, which is where personalization logic goes to rot.
Where do the embeddings come from, and how do they stay current?
Embeddings come from a function that runs when content changes, and they stay current because that function is triggered by the same publish event that updates the document, not by a nightly batch. The staleness problem in most stacks is a scheduling problem: the index is rebuilt on a timer, so it is correct only at the moments the timer happens to fire. Tie embedding generation to the content lifecycle instead and the window of inconsistency shrinks from hours to seconds.
In Sanity, Functions can run serverless logic in response to content events, so when a document is published or edited, a function can call an embedding model and write the resulting vector back onto the document as a field. Because the vector lives on the document in the Content Lake, the next recommendation query reads the fresh embedding automatically; there is no separate index to reindex and no lag between the edit and the ranking that reflects it. The App SDK lets teams build the surrounding tooling, like a Studio view that shows which documents have current embeddings, directly where editors already work.
The operational payoff is that content people and platform people stop negotiating a reindex schedule. An editor rewrites a product description to emphasize a new use case; the publish fires the function; the embedding updates; the recommendations shift to match the new copy within seconds. This is the practical meaning of content and its representation living in one system. The embedding is not a downstream artifact you hope is fresh, it is derived state kept current by the event that changed the source, which is the only reliable way to keep a retrieval layer honest.
How does governance survive when recommendations are automated?
Governance survives automated recommendations when the same publish state, roles, and release scheduling that govern manual content also govern what the recommendation query can return, so automation reads through the governance layer rather than around it. The nightmare version of personalization is a recommender that has its own copy of the content and therefore its own, unauditable idea of what is allowed to appear. If the ranking service can surface a draft, an expired campaign, or a document an author lacked permission to publish, personalization has quietly become a governance bypass.
Because a GROQ recommendation query runs against the Content Lake, it inherits the same notion of published versus draft that every other read respects. Content Releases and Scheduling mean an item that is staged for a future launch is simply not part of the published dataset the query sees, so it cannot leak into a recommendation early. Roles & Permissions constrain who can change the content that feeds the model, and Audit logs record those changes, so when a wrong item surfaces you can trace which edit or embedding update caused it.
The reframe here is that governance and automation are usually posed as a trade-off, more automation meaning less control. When retrieval reads through the same store and the same rules as human editing, that trade-off dissolves: the recommendation surface is exactly as governed as the rest of the content operation, because it is the rest of the content operation, queried a particular way. For teams with compliance obligations, it helps that Sanity is SOC 2 Type II compliant, supports GDPR requirements and regional data residency, and publishes its sub-processor list, so the store your recommendations read from is the store your auditors already understand.
What is the architectural payoff of collapsing the recommendation stack?
The architectural payoff of collapsing the recommendation stack is fewer systems in the request path, which directly reduces latency, failure modes, and the surface area you have to operate. Every service you remove from the critical path between a page load and a rendered recommendation removes a network hop, a possible timeout, a cache to invalidate, and an ID scheme to reconcile. Collapsing four systems into one query is not a marginal cleanup; it changes what can go wrong.
Sanity is best understood here as the Content Operating System for the AI era: a shared foundation where content, its embeddings, its query layer, and its governance are the same substrate rather than four integrated products. That framing matters because it explains why the single-query pattern is available at all. Legacy headless CMSes stop at publishing and hand off ranking to a bolt-on service; a Content Operating System operates content end to end, so retrieval, filtering, and delivery are native reads, not integrations you assembled. The five-differentiator lens applies cleanly: CMSes create silos, and a recommendation stack is a silo problem in disguise.
Concretely, the win is that a recommendation feature ships as a query and a function rather than as a distributed system with its own database, deploy pipeline, and on-call rotation. TypeGen turns the schema behind those documents into TypeScript, so the shape a recommendation query returns is typed end to end and a frontend that renders a card gets a compile error, not a runtime hole, when the content model changes. That is the quiet payoff of one store: the guarantees you rely on for ordinary content, freshness, typing, and governance, are the same guarantees your personalization inherits for free.
Serving personalized recommendations: single content API vs. bolt-on stacks
| Feature | Sanity | Pinecone + Contentful | Contentful | Strapi |
|---|---|---|---|---|
| Similarity + filters in one query | Native: text::semanticSimilarity() scores while match() and comparison operators filter in the same GROQ expression, one round trip. | Vector search and content filtering are separate calls; you rank IDs in Pinecone, then re-fetch and re-filter content in Contentful. | GraphQL filters on structured fields, but no native vector similarity; semantic ranking needs an external vector service. | REST/GraphQL filtering on fields; semantic ranking requires an external embedding store and a custom service to blend them. |
| Where embeddings live | Stored as a field on the same document in Content Lake, so the ranking read and the content read are the same read. | Held in Pinecone, keyed to Contentful entry IDs; content and vector are two systems to keep aligned. | No first-class embedding field; vectors live in a separate store you provision and index yourself. | No native vector field; embeddings live in an external database you host alongside Strapi. |
| Keeping the index fresh on publish | Functions fire on content events to re-embed and write the vector back, so edits reflect in recommendations within seconds. | Webhooks can trigger a re-embed job, but you build and operate the sync pipeline that writes back to Pinecone. | Webhooks exist; the re-embed and reindex pipeline to the external vector store is yours to build and run. | Lifecycle hooks can trigger jobs, but the embedding refresh pipeline and vector store are self-hosted and self-operated. |
| Governance over what can be recommended | Query reads through published state, Content Releases scheduling, and Roles & Permissions, so drafts and staged items cannot leak. | Pinecone has no notion of publish state; you must re-enforce governance in application code after the vector call. | Publish state governs the CMS read, but the external ranking layer can surface IDs the content read then has to reject. | Draft/publish exists in Strapi, but the separate ranking service must reapply those rules itself. |
| Type safety end to end | TypeGen generates TypeScript from the schema, so a recommendation query's return shape is typed into the frontend. | Codegen available for Contentful entries; the Pinecone hop returns untyped IDs you reconcile manually. | GraphQL codegen types content responses; no typing across the separate vector-ranking boundary. | Types available via plugins, but the external ranking service sits outside the generated types. |
| Systems in the request path | One: a GROQ read against Content Lake returns render-ready, filtered, ranked documents. | At least two: Pinecone for ranking, then Contentful to resolve and filter content, plus glue code. | Two or more: Contentful for content plus an external vector service for semantic ranking. | Two or more: Strapi for content plus a self-hosted vector store and ranking service. |