Blog
How to Design Stable Content APIs for Frontend Teams
March 20, 2026
Unstable content APIs are one of the biggest causes of frontend regressions. The CMS added a field, the default filter changed, or a preview build hit a different state than production—and suddenly components throw or render empty states nobody tested.
Stability is not only “good REST design.” It is explicit contracts: what is returned, for which locale and publish state, and how pagination and sorting behave under load.
This post covers team-level principles first, then how Noma’s content API (Noma Core: nomacms/nomacms-core) implements those ideas so you can rely on them in Next.js, mobile apps, or any API consumer.
Principles your team should enforce
1) Treat the schema as a contract
- Field names and types are part of the public API. Renaming or retyping a field is a breaking change unless you version or migrate clients.
- Prefer additive changes: new optional fields, new collections—rather than reshaping existing payloads without notice.
In Noma, you can mark fields hiddenInAPI in field options so they stay in the editorial UI but never appear in ContentEntryResource output—useful for internal notes without exposing them to the storefront.
2) Make publish state and locale explicit in client code
Defaults are convenient and dangerous. Your frontend should document which query parameters it sends for:
state— draft vs publishedlocale— which language you are rendering
Never assume “whatever the server default is” matches your deployment (preview vs production).
3) Deterministic sorting and pagination
Any list that can paginate must have a stable sort. If two rows tie, pagination can shuffle between requests unless there is a tie-breaker (for example by id).
4) One thin SDK or fetch layer
Centralize HTTP calls in one module (per app) that:
- Injects auth headers (
project-id, bearer token) - Applies default query params for production builds
- Maps errors to typed outcomes
That layer is where you pin “our app always requests state=published for public pages.”
How Noma’s content API supports stable delivery
Noma exposes collection-scoped routes under /api/{collection} with project-id (and authentication) on the request. The implementation lives in App\Http\Controllers\Api\ContentController.
Below is behavior you should treat as contract-level when building frontends.
List entries: GET /api/{collection}
Publish state
- Query
stateacceptsdraftorpublished. - Default is
published. Invalid values fall back topublished—do not rely on errors to catch typos.
Locale
- Optional
localefilters entries to that locale string. - For singleton collections, if
localeis omitted and the project hasdefault_locale, the API filters todefault_localeautomatically. That is a convenience with real behavior: document it for your site settings / global singleton fetches.
Field payload
- Responses expose entry metadata plus a
fieldsobject (viaContentEntryResource). exclude— comma-separated field names to omit from the loaded field values (smaller payloads for list views).
Advanced filters
where— structured filtering on core columns (id,uuid,locale,state, timestamps,published_at) and on custom field values (with operators such as eq, lt, in, like, null, etc., depending on the request shape).
Sorting
sort— supports core columns likepublished_at,created_at, and custom field names where implemented. For pagination, field-based sorting uses database-level ordering with a stable tie-breaker oncontent_entries.idwhere applicable—important for consistent pages.
Pagination
paginate— Laravel-style page size; usesappends($request->query())so links keep your filters.limit/offset— alternative window (offset applies only when limit is set).count— returns{ count }for the current filter set without returning rows.first— returns a single entry resource instead of a collection (useful for “pick one” queries when filters narrow to one row).
Singleton collections
- If the collection is a singleton,
GET /api/{collection}returns one object (not an array): the first matching entry after filters, or 404 if none. Design your client types accordingly (TvsT[]).
Single entry by UUID: GET /api/{collection}/{uuid}
Same ideas as list:
statedefaults topublishedwith the same validation rule.localewhen you need to disambiguate (see core for edge cases).excludeto trim large fields.translation_locale— if set, resolves the linked translation in that locale (sametranslation_group_id), with the samestateas the resolved entry query. If the translation is missing or wrong state, you get 404 with a clear message—see How to Model Multilingual Content Without Duplicate Entries.
Writes (high level)
Creating and updating entries uses locale, state, and data payloads; invalid state on create normalizes to draft. Your frontend should still send explicit values in production code paths so behavior does not depend on server defaults.
Avoid hidden behavior in your app layer
The API documents defaults; your product should not add more:
| Risk | Mitigation |
|---|---|
| Preview shows draft, production shows published | Same components; branch on state (and possibly preview tokens) in the fetch layer |
Singleton without locale assumes default_locale | Document for every global settings fetch |
| Translation lookup 404 | Handle “no translation” and optional fallback locale in UI |
| Huge list payloads | Use exclude for cards; fetch full entry only on detail routes |
| Sorting without tie-breakers | Prefer API sorts that include stable id ordering for paged lists |
Contract testing for content APIs
Before release, automate checks that mirror production:
- Required fields — every rendered template has non-null data for fields it reads (or explicit empty UI).
- List + pagination — first and second page return disjoint sets with stable ordering.
- Draft vs published — preview and public builds request the intended
state. - Locale — at least one path per supported locale; singleton + default locale behavior covered.
- Regression — snapshot or schema-validate a sample
ContentEntryResourceshape after schema changes.
Why this matters
When state, locale, pagination, and field visibility are explicit, UI code stops guessing. Teams ship smaller diffs, fewer production incidents, and faster iteration on content models—whether you use Noma or any other headless CMS.
Related:
Implementation reference (Noma Core)
App\Http\Controllers\Api\ContentController—index,show, create/update patterns;state,locale,exclude,where,sort,paginate,limit,offset,count,first, singleton branch,translation_localeonshowApp\Http\Resources\ContentEntryResource— shapesfieldsoutput; respectshiddenInAPI- OpenAPI annotations on the controller document parameters for API consumers and generated clients