Choosing a CMS With a REST API: What to Evaluate Beyond Endpoints
A common failure goes like this: a product page needs a post, its author, and three related articles, and the CMS API answers with four round trips and a rate-limit error during the build.
A common failure goes like this: a product page needs a post, its author, and three related articles, and the CMS API answers with four round trips and a rate-limit error during the build. The best CMS for REST APIs is the one whose HTTP API filters, projects, resolves references, and paginates in one request, then serves cached reads from a CDN. In Sanity, a single GROQ query sent to /<version>/data/query does all four, and cached responses from apicdn.sanity.io are not rate limited.
A list of endpoints hides those costs. If the API returns whole documents, the frontend fetches more than it needs, joins references in application code, and has to handle its own caching. Builds get slower, static generation runs into concurrency caps, and an unpinned API version can ship a breaking change that nobody planned for.
This guide looks at the API as something you operate in production, not as a list of routes. It covers the query shape you need, how REST, GraphQL, and query-language APIs differ, and the operational criteria that decide whether an API holds up: caching, rate limits, versioning, webhooks, real-time updates, auth tokens, and SDKs. Sanity is the worked example, compared against Contentful, Strapi, and Hygraph.
What should a CMS API do beyond exposing endpoints?
A CMS API should let you filter, project, resolve references, and paginate in a single request, because those four operations decide how many round trips a page costs. The number of endpoints tells you very little. A CMS can expose fifty REST routes and still make you fetch a blog post, then its author, then its category, then its related posts, each as a separate call.
Filtering means the server narrows the results by any field, including fields on referenced documents, so the client never downloads records it will throw away. Projection means you name the exact fields and shape of the response, and you can rename or compute values on the way out. That keeps payloads small on mobile connections. Reference resolution means a pointer to another document, such as an author, product, or category, comes back as the document itself instead of an ID that needs another request. Pagination means slicing results predictably, ideally with the same syntax at every level of nesting.
In Sanity, all four happen in one GROQ query sent over HTTP. Here is a category listing page:
*[_type == "post" && category->slug.current == $category] | order(publishedAt desc) [0...10] { title, publishedAt, "author": author->name, "related": related[]->{ title, slug } }
The filter in the brackets reaches through a reference with the `->` operator. The `[0...10]` slice handles pagination. The projection in braces renames fields and pulls in the author and related posts inline. The query goes to the /<version>/data/query endpoint as a GET request, or as a POST once the headers would go over 15 KB.
Here is a useful test during evaluation. Take your most complex page, list every piece of data it renders, and count how many requests each candidate API needs to fetch it. Then check whether the response contains only what the page uses. Two requests with tight payloads beat one request that returns three times the data, and both beat six.
Is REST, GraphQL, or a query language over HTTP the better fit?
A REST-style HTTP API with a flexible query language fits best when you want the caching and simplicity of plain HTTP without being locked into fixed response shapes. Classic REST gives each resource a URL and a fixed representation. That makes it easy to cache and easy to debug with curl, but the server decides the shape. You either fetch too much or keep adding query parameters until the URL turns into its own language. Strapi 5 shows how far parameters can go. Its `filters`, `populate`, `fields`, `sort`, and `pagination` parameters cover most needs, but its docs say nested pagination on populated relations is not supported, and rest.maxLimit caps page sizes.
GraphQL hands control of the shape to the client. You declare the fields you want, and the server returns that tree. Hygraph is GraphQL native, and Contentful offers a GraphQL API alongside its REST Content Delivery API. GraphQL has real strengths: typed schemas, introspection, and a mature set of clients, code generators, and IDE plugins. It also has costs. There is a schema layer to maintain, traffic is mostly POST requests that take extra work to cache, and what you can express (computed fields, filters on joined data) depends on what each vendor's schema exposes.
A query language over HTTP sits between the two. The request is still an HTTP call to a stable URL and the response is still JSON, but the query string carries filters, joins, projections, and ordering. GROQ, Sanity's query language, works this way. Sanity also exposes GraphQL at /<version>/graphql for teams that prefer it.
The trade-off is real. GROQ is a new language your team has to learn, and GraphQL has much broader third-party tooling. If your organization has standardized on GraphQL clients and schema federation, that ecosystem deserves serious weight. If your main problems are round trips and response shape, a query language that can project and resolve references in one expression usually removes more application code than a schema layer does.
How do caching and a CDN change which CMS API you should pick?
Caching determines your real-world latency and how exposed you are to rate limits, so a CMS API should include a CDN you can opt into per request instead of one you have to build. A fast origin server still loses to an edge cache for a visitor on another continent, and a cache hit is a request that never counts against your quota.
Sanity runs two delivery hosts. api.sanity.io is uncached and is the default, so it always returns the freshest data. That suits previews, scripts, and server-side jobs that read and then write. apicdn.sanity.io is the opt-in API CDN, recommended for frontends that serve end users. It caches GROQ requests to /<version>/data/query and GraphQL requests to /<version>/graphql. Authenticated requests carry a bearer token and are cached separately for each unique token. This matters when private datasets serve different audiences, because a response cached for one token is never served to another. Listeners, including the Live Content API, bypass the cache entirely.
Contentful makes a similar split. Its Content Delivery API is CDN backed and cache hits don't count against rate limits, while its Preview and Management APIs are uncached. Hygraph offers a CDN-cached endpoint for its GraphQL Content API. Self-hosted Strapi leaves caching to you. You put a CDN or reverse proxy in front of it and write the invalidation rules yourself. Some teams want exactly that control. Others find they have accidentally built and now maintain their own caching layer.
Ask every vendor these questions. Which host is cached and which isn't? Are authenticated responses cached, and how are they kept separate? What happens when the origin is down? How does a content change clear the cache, and how quickly? The answers predict your future incidents better than any feature matrix.
Cached reads keep working when the origin is down
What rate limits and concurrency caps will a CMS API hit in production?
Rate limits cause trouble in two places: spikes of uncached traffic and the burst of parallel requests during a build. Check the limits for uncached requests, mutations, and concurrency separately. A limit that looks generous per second can still break a static build that sends hundreds of queries at once.
Sanity publishes its limits per IP and per dataset. The direct API allows 500 requests per second per IP for API calls, 25 per second for mutations, and 25 per second for uploads. Each dataset allows 500 concurrent queries and 100 concurrent mutations. Going over returns HTTP 429. Cached API CDN responses are not rate limited at all. The official JavaScript SDK, @sanity/client, retries a 429 on queries five times by default with exponential backoff. It does not retry mutations, so the docs recommend batching mutations into transactions and limiting concurrency with a library such as p-limit. Static site builds are where teams most often hit the 500-concurrent-query cap, usually because every page fetches its data in parallel.
Contentful's Content Delivery API limits uncached requests to 55 per second by default, with higher limits on some plans, and CDN cache hits don't count. Hygraph documents limits on uncached requests per second and on concurrency. Self-hosted Strapi has whatever limits your infrastructure and configuration set, which gives you flexibility and also makes the limits your problem.
SDK behavior belongs in this evaluation too. Check whether the official client retries automatically, which operations it retries, and whether it gives you typed results. On the Sanity side, @sanity/client handles query retries and takes a pinned apiVersion, and TypeGen generates TypeScript types from your schema so mistakes show up at compile time. Also check how auth tokens are scoped. Frontends should use read tokens, write tokens should stay on the server, and each environment should have its own tokens so a leaked preview key can't change production content.
How should a CMS API handle versioning without breaking your frontend?
A CMS API should pin every request to a version you choose, warn you before that version is retired, and fail clearly once it's gone. Silent upgrades are how a frontend breaks on a Tuesday with no deploy in the history.
Sanity uses date-based versions in the URL path, a scheme inspired by Stripe. A request looks like https://<projectId>.api.sanity.io/v2026-06-24/data/query/production?query=*, and clients set the version as a static string, for example apiVersion: '2026-07-28'. When a version is deprecated, responses include the X-Sanity-Warning and X-Sanity-Deprecated: true headers, so monitoring can flag them well before anything breaks. Removed versions return HTTP 410. Version X is experimental and should stay out of production code. New GROQ operators and functions count as backward compatible, so they can reach older versions without a version change.
Whatever the vendor, the question to ask is who controls when you upgrade. With date pinning, you move forward when you decide to, test the new date in a branch, and then ship. With unversioned APIs or a single account-wide version, the vendor decides for you. Ask each candidate: Is the version set on every request or once for the whole account? How are deprecations announced: in response headers, by email, or only in a changelog? What status code does a retired version return? Can new features reach you without a version change?
Versioning also applies to your content model. Your schema will change, and the API should let old and new shapes exist side by side while you migrate. Query-side projections help. A GROQ projection can rename a field or fill in a default with coalesce(), so the frontend keeps seeing the same shape while the documents underneath are migrated one batch at a time.
Don't compute apiVersion at runtime
Which webhooks and real-time features keep content fresh across channels?
Webhooks and real-time subscriptions should be filterable, so only the changes you care about reach your systems. They should also be shapeable, so the receiving system gets the payload it needs without fetching again. A webhook that fires on every edit with only a document ID turns your receiver into one more API client, with its own rate-limit problems.
Sanity's GROQ-powered webhooks take two inputs: a GROQ filter that decides which document changes fire the webhook, and a GROQ projection that shapes the outgoing payload. They cover create, update, and delete events. For example, a search indexer can subscribe only to products in one category and receive the title, price, and the brand name pulled through a reference, ready to index.
For live frontends, the Live Content API uses sync tags. Every Content Lake response includes sync tags. Clients subscribe to invalidation events and refetch only when one of their stored tags matches. A page showing ten products refetches when one of those products changes, not when someone edits an unrelated blog post. Sync Tag Invalidate Functions can hold these events back (waitFor=function) until CDN cache invalidation has finished, so clients don't refetch stale data from the edge.
When the reaction needs code instead of a call to your own server, Functions run on Sanity's infrastructure. Document functions trigger on create, update, or delete, and publishing a document fires an update on the published version. Other function types handle Media Library assets, sync tag invalidation, scheduled cron jobs, and PubSub calls between functions. You write them in TypeScript or JavaScript and declare them in a Blueprint file (sanity.blueprint.ts). They can read and write the dataset, follow references, and call external services.
When comparing vendors, ask three things. Can webhooks filter on content values and not only on document type? Can you shape the payload? How do real-time clients avoid refetching everything after every change?
Can one CMS API serve websites, apps, and AI agents?
One CMS API can serve websites, apps, and AI agents if the content is structured and the query language can express what each of them needs. That removes the need for a separate sync pipeline for each channel. The usual failure is three copies of the same content: the website reads the CMS, the app reads a cached export, and a support bot reads a vector index rebuilt overnight. Each copy needs its own refresh job, and each job is another place for content to go stale.
Sanity is the Content Operating System for the AI era. For an API evaluation, that means one Content Lake and one query language serve every consumer. Websites and apps call /<version>/data/query through the API CDN. Agents reach the same dataset through GROQ mode in Sanity Context, which queries at request time through the Context MCP endpoint. GROQ mode needs a deployed schema (sanity schema deploy, Studio v5.1.0 or later), because the schema tells the agent where to look. A request like "size L latex gloves, under $200 a pallet" becomes a filter over product documents. It is exact across hundreds of thousands of records, with no build step and nothing to keep in sync.
When the useful details live in prose fields, turn on dataset embeddings and keep using GROQ. A single query can then combine structured filters with semantic similarity. One rule applies: text::semanticSimilarity() is only valid as an argument to score(), so it ranks results and never filters them. Filter first, then rank:
*[_type == "product" && category == $category && price < $maxPrice] | score(boost([title] match text::query($queryText), 2), text::semanticSimilarity($queryText)) | order(_score desc) [0...10] { _id, title, price, "stock": stockLocation->{ name, available } }
For most teams, agents are a later consumer, not the reason to pick an API. If an API already handles filtering, projection, and reference resolution in one request, you won't need to replace it when agents arrive.
How CMS APIs compare beyond endpoints: Sanity, Contentful, Strapi, and Hygraph
| Feature | Sanity | Contentful | Strapi | Hygraph |
|---|---|---|---|---|
| Query model over HTTP | GROQ sent to /<version>/data/query as a GET, or as a POST once headers pass 15 KB. GraphQL is also available at /<version>/graphql on the API CDN. | REST Content Delivery API with query parameters, plus a separate GraphQL API. Preview and Management APIs handle drafts and writes and are not cached. | Strapi 5 REST with `filters`, `populate`, `fields`, `sort`, and `pagination` parameters, plus an optional GraphQL plugin for teams that want typed queries. | GraphQL-native Content API. A strong fit for teams standardized on GraphQL tooling, but a REST-style query-string workflow is not the default. |
| Response shaping (projection) | GROQ projections select, rename, and compute fields inside the query, so one request returns the exact JSON shape the component renders. | Field selection through the GraphQL API. The REST Delivery API returns entries with linked content resolved through the `include` parameter. | The `fields` parameter selects attributes and `populate` pulls in relations, which gives reasonable control over payload size without a separate query language. | GraphQL selection sets return exactly the requested fields, in a shape defined by the schema Hygraph generates from your content models. |
| References resolved in one request | The `->` operator resolves references inline in both filters and projections, so `author->name` or `related[]->{title}` needs no second call. | Linked entries resolve through the `include` parameter on REST or through nested GraphQL queries, so references can come back in a single response. | `populate` resolves relations in one request. The docs say nested pagination on populated relations is not supported. | Relations resolve through nested GraphQL queries in one request, within Hygraph's documented limits on uncached requests and concurrency. |
| Pagination | Slice syntax `[0...10]` works at the top level and on nested arrays in the same query, after filtering and ordering. | Collections paginate through Delivery API query parameters. On GraphQL, each collection field takes its own pagination arguments. | The `pagination` parameter pages top-level results, capped by rest.maxLimit. Nested pagination on populated relations is not supported. | Pagination uses GraphQL query arguments. Check page-size caps and how nested relations paginate against your largest collection before committing. |
| CDN caching and rate limits | apicdn.sanity.io caches GROQ and GraphQL, and cached responses are not rate limited. Direct API: 500 req/s per IP, 25 mutations/s, and 500 concurrent queries per dataset. | CDN cache hits don't count against rate limits. Uncached Delivery API requests are limited to 55 req/s by default, with higher limits on some plans. | Self-hosted Strapi leaves the CDN, cache invalidation, and rate limiting to your infrastructure. You get full control and full operational ownership. | CDN-cached endpoint for the Content API, with documented limits on uncached requests per second and on concurrency. |