Noma

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 published
  • locale — 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 state accepts draft or published.
  • Default is published. Invalid values fall back to published—do not rely on errors to catch typos.

Locale

  • Optional locale filters entries to that locale string.
  • For singleton collections, if locale is omitted and the project has default_locale, the API filters to default_locale automatically. That is a convenience with real behavior: document it for your site settings / global singleton fetches.

Field payload

  • Responses expose entry metadata plus a fields object (via ContentEntryResource).
  • 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 like published_at, created_at, and custom field names where implemented. For pagination, field-based sorting uses database-level ordering with a stable tie-breaker on content_entries.id where applicable—important for consistent pages.

Pagination

  • paginate — Laravel-style page size; uses appends($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 (T vs T[]).

Single entry by UUID: GET /api/{collection}/{uuid}

Same ideas as list:

  • state defaults to published with the same validation rule.
  • locale when you need to disambiguate (see core for edge cases).
  • exclude to trim large fields.
  • translation_locale — if set, resolves the linked translation in that locale (same translation_group_id), with the same state as 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:

RiskMitigation
Preview shows draft, production shows publishedSame components; branch on state (and possibly preview tokens) in the fetch layer
Singleton without locale assumes default_localeDocument for every global settings fetch
Translation lookup 404Handle “no translation” and optional fallback locale in UI
Huge list payloadsUse exclude for cards; fetch full entry only on detail routes
Sorting without tie-breakersPrefer API sorts that include stable id ordering for paged lists

Contract testing for content APIs

Before release, automate checks that mirror production:

  1. Required fields — every rendered template has non-null data for fields it reads (or explicit empty UI).
  2. List + pagination — first and second page return disjoint sets with stable ordering.
  3. Draft vs published — preview and public builds request the intended state.
  4. Locale — at least one path per supported locale; singleton + default locale behavior covered.
  5. Regression — snapshot or schema-validate a sample ContentEntryResource shape 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\ContentControllerindex, show, create/update patterns; state, locale, exclude, where, sort, paginate, limit, offset, count, first, singleton branch, translation_locale on show
  • App\Http\Resources\ContentEntryResource — shapes fields output; respects hiddenInAPI
  • OpenAPI annotations on the controller document parameters for API consumers and generated clients