CCelestiallines.com
← Back to blog

Drupal JSON:API: A Beginner's Guide to Headless Drupal

By Pawan Singh · Jun 2026

This site's own blog runs on exactly this setup — a headless Drupal backend serving content over JSON:API to a separate Next.js frontend. Here's what that actually involves, including the gotchas that only show up once you're querying real content instead of a demo.

Why Go Headless

A headless Drupal setup keeps content editing in Drupal's mature, battle-tested admin UI — content types, fields, media library, revisions, permissions — while letting a separate frontend (React, Next.js, anything that can make an HTTP request) own the actual rendering. Drupal becomes a pure content API; it never generates the HTML a visitor sees. This is sometimes called "decoupled" or "progressively decoupled" Drupal, and it's the right call when you want frontend flexibility (a modern JS framework, a fully custom design system) without giving up Drupal's editorial tooling.

JSON:API vs GraphQL vs a Custom REST Controller

Drupal actually offers three ways to expose content as an API, and it's worth knowing why JSON:API is usually the right default:

  • JSON:API (core module) — zero custom code. Every content entity is automatically exposed following the JSON:API spec, with filtering, sorting, pagination, and relationship inclusion built in. The tradeoff is less query flexibility than GraphQL — you're working within the spec's conventions, not writing arbitrary queries.
  • GraphQL (contributed module) — lets a frontend request exactly the fields it needs in a single query, including nested relationships, which can reduce round-trips for complex pages. The cost is a steeper setup and a schema to maintain.
  • A custom REST controller — full control over the exact response shape, at the cost of writing and maintaining the endpoint yourself. Reach for this only when JSON:API's conventions genuinely don't fit (e.g. an endpoint that aggregates data across unrelated content types in a bespoke way).

For most content-driven sites — blogs, marketing pages, documentation — JSON:API's zero-custom-code tradeoff wins. Reach for GraphQL when you have a genuinely complex, deeply nested content graph and a frontend team that wants query control; reach for a custom controller only for the specific endpoints JSON:API can't express.

Enable JSON:API

The jsonapi module ships with Drupal core — just enable it:

vendor/bin/drush en jsonapi -y

Every content entity is now exposed automatically at a predictable URL, e.g. articles at /jsonapi/node/article. Visit /jsonapi itself to get a full index of every resource type your site exposes — useful for discovering what's available without digging through Drupal's admin UI.

Querying Content

The query syntax follows the JSON:API spec, which supports sorting, pagination, filtering, sparse fieldsets, and relationship inclusion — all without writing any backend code:

GET /jsonapi/node/article?sort=-created&page[limit]=10

A few more of the query parameters worth knowing:

  • Filteringfilter[status]=1 for published-only content, or filter on a referenced taxonomy term's name: filter[field_tags.name]=Laravel.
  • Sparse fieldsetsfields[node--article]=title,body returns only the listed fields instead of the full entity, cutting payload size for list views that don't need everything.
  • Including relationshipsinclude=uid,field_image resolves entity reference fields into full nested objects in the response's included array (see the gotcha below — without this, you only get a bare reference ID).
  • Paginationpage[limit]=10&page[offset]=20 for offset-based paging; JSON:API responses also include links.next/links.prev URLs you can follow directly instead of computing offsets yourself.

A Few Gotchas

  • Computed fields can't be filtered server-side. The URL alias (path.alias) is a computed field, so filter[path.alias]=... returns a 500 rather than the empty result you might expect. Fetch the full collection and match the alias client-side instead, or install the jsonapi_extras module, which adds alias-based lookup support JSON:API doesn't have natively.
  • Anonymous access requires explicit permission. Grant the "Access GET requests" permission to the Anonymous role under JSON:API's permissions — without it, every request 403s even for published, publicly-viewable content, which is a confusing first error for anyone new to the module.
  • Relationships need include, always. By default you only get a reference ID for entity reference fields (an article's author, its image, its tags) — add ?include=uid,field_image,field_tags to get the full related entity in the response's included array. Forgetting this is the most common reason a first JSON:API integration looks broken (author name and image URL both "missing") when the data was actually always there, just not the entity, only its ID.
  • Field names in the response don't always match the UI. A field created via the Drupal UI as "Tags" is exposed as field_tags in JSON:API — the machine name, not the human label. Check the field's machine name under Structure → Content types → [type] → Manage fields if a query returns unexpected null values.

Writing Content via JSON:API

JSON:API isn't read-only by default — if permissions allow it, you can create and update content too. A write request needs the application/vnd.api+json content type and the spec's nested data structure:

POST /jsonapi/node/article
Content-Type: application/vnd.api+json

{
  "data": {
    "type": "node--article",
    "attributes": {
      "title": "New Post",
      "body": { "value": "Content here", "format": "full_html" }
    }
  }
}

Writes need authentication — anonymous users should never have create/update permissions on a public content API. A common pattern (used on this site's own contact form) is a dedicated, least-privilege bot user authenticated via HTTP Basic Auth, scoped to exactly the content type and operation it needs and nothing else.

Fetching Media and Images

An image field doesn't return a usable URL directly — it returns a reference to a media entity, which itself references a file entity that holds the actual URI. Getting from a field to a displayable image URL means including through both levels:

GET /jsonapi/node/article?include=field_image.field_media_image

The response's included array will then contain both the media entity and the file entity, whose attributes.uri.url holds the actual path. This two-hop structure trips up almost everyone integrating Drupal media for the first time.

Locking It Down for Production

For a public content API, restrict JSON:API to safe, anonymous GET requests only via Drupal's own permissions (don't grant create/update/delete to Anonymous), and keep the Drupal admin UI itself behind authentication on a separate, non-indexed subdomain — not just relying on Drupal's own login wall. Rate limiting and caching are worth handling at the reverse proxy layer in front of Drupal rather than in Drupal itself, since that's the layer that can actually absorb traffic spikes cheaply.

Securing the Backend Domain

If your Drupal instance lives on its own subdomain purely to serve the API, set noindex, nofollow and a restrictive robots.txt on it — search engines have no reason to crawl a JSON API, only your frontend's actually-rendered pages should ever be indexed. Also confirm cache invalidation is wired up correctly: Drupal tags its JSON:API responses with cache tags tied to the underlying content, but a reverse proxy or CDN in front of it needs to respect and invalidate those tags (via the Purge module or equivalent) or you'll serve stale content after edits.

Common Issues

  • 500 error when filtering by a computed field — the path.alias gotcha above; filter client-side or use jsonapi_extras.
  • 403 on every request, even published content — missing the "Access GET requests" permission for the requesting role.
  • Related entity fields showing only an ID — missing ?include= for that field.
  • Image URLs missing entirely — the media/file two-hop include above, forgotten.
  • Stale content served after an edit — a caching layer (CDN, reverse proxy) not respecting Drupal's cache tags.
  • Writes silently failing with a 403 — the authenticated user's role lacks the specific "create"/"update" permission for that content type, separate from whether they can log in at all.

Need a headless Drupal backend wired up to a custom frontend? Get in touch — I work as a freelance Drupal developer.

Comments