How to quickly configure Drupal as a decoupled (headless) API-first system

Written by: Sergey Linkin

picture of two computer monitors on a desk

Drupal is both a coupled CMS for editors who need control over the presentation as well as a headless CMS for developers who want to build a custom front-end.

Dries Buytaert

First off, let’s be clear: Drupal is not a coupled-only or API-only CMS, but it is actually API-first! It allows us to create and store content in the back-end and we are able to configure it where and how we want.

There’s a lot of information on how to make Drupal decoupled or headless in combination with some frameworks, applications, touch screens or other front-ends. And if you are looking to configure Drupal as not a coupled-only CMS, you’ll have to understand the differences and the benefits. We feel the most ideal option is to use Drupal for your site and headless Drupal for your apps.

This diagram illustrates the differences between a coupled — but headless-enabled — Drupal website and a headless CMS with various front ends receiving content.

API specification: REST vs JSON:API vs GraphQL

So, you’ve decided that your Drupal should be a headless CMS. What specifications should you choose for building an API and receiving the data from a CMS?

There is a powerful comparison of the most common specifications for REST, JSON:API, GraphQL based on request efficiency, operational simplicity, API discoverability, and more. The result as you can see is very positive for JSON:API.

Comparison of REST, JSON:API and GraphQL — three different web services implementations

Based on this analysis, for Drupal core’s needs, we rank JSON:API above GraphQL and GraphQL above REST. As such, I want to change my recommendation for Drupal 8 core. Instead of adding both JSON:API and GraphQL to Drupal 8 core, I believe only JSON:API should be added.

Dries Buytaert

The information above helps us to choose a JSON:API as a basic specification with useful documentation and spells a good future and active support of the module as part of the Drupal core, so we can rely on it.

Great, let’s get started!

Basic configuration of headless CMS

First you have to install the latest version of Drupal. It was 8.7.3 at the moment of this writing, but all information is absolutely relevant to the latest version of Drupal 9!

So, go to the your project directory and run the following code in your terminal (composer is used for installation):

composer create-project drupal/recommended-project drupal
cd drupal

or download directly from using:

mkdir drupal
cd drupal
curl -sSL | tar -xz --strip-components=1

Then you need to edit the file composer.json in the root directory of Drupal to update existing vendors and add the necessary modules to your project. The following lines should be placed to the “require” section of the file.

"drupal/jsonapi_extras": "^3",
"drupal/paragraphs": "^1",
"drupal/consumers": "^1",
"drupal/simple_oauth": "^4",
"drupal/subrequests": "^3"

Now go back to the terminal and execute the command:

composer update
cd web

Once you’ve updated project, you can set up a working localhost of your site (like http://localhost, a lot of documentation can be found online for your OS) and then run Drupal installation, but it might be even better for us to prepare a quick Drupal demo with the following code (Standard installation profile is used):

php core/scripts/drupal quick-start standard
The basic installation of the site is completed

Great! Now you’ve completed the site installation and can move forward.

JSON:API is a zero configuration module which gives access to all Drupal entities for reading out of the box. But you should consider not only the reading of the data, but also the possibility of creating or updating it.

Once it’s enabled, you need to go to the module settings page (/admin/config/services/jsonapi) and change the ‘Allowed operations’ field to the ‘Accept all JSON:API create, read, update, and delete operations’ value.

Accept create, read, update and delete JSON:API operations

Also, the API must be customized for security reasons, and you can do this using JSON:API Extras module. Module has already been installed via the composer and should be enabled on the ‘Extend’ page (/admin/modules).

Go to the settings page (/admin/config/services/jsonapi/extras), check in ‘Include count in collection queries’ option and change ‘Path prefix’ of our API from /jsonapi to /api/json, for example.

Later, you could also disable extra resources which can’t be used via the API on the ‘Resource overrides’ tab of the settings page.

Please remember to clear the Drupal cache (/admin/config/development/performance) to apply routing changes.

The last thing we need to create before using the API is some content. For example, an article (/node/add/article).

The first article is created

Ok, we are now ready to make the first API request!

Let’s check this using the Postman tool.

You can also see or ‘Run in Postman’ the samples of requests and responses in shared Demo collection.

Go to Postman, select GET method, enter your request URL http://localhost/api/json/node/article and click the Send button.

The response with the article data

Voila! We got the first response! It’s a pretty simple process, isn’t it?

Here is where you can see all the information on all articles on your site.

Next, we will clarify some of the rather complex CRUD operations.
But for simple usage of Drupal, you can stop with this example of getting basic data and extend it with a great video from Dries Buytaert.

Advanced usage of Drupal power

Let’s look at an interesting feature that we have implemented in the application to provide all CRUD operations on entities using our API — Workouts.

There will be a tree of content types, such as WorkoutExercise and Paragraph Exercise as a combined entity reference with some fields. Let’s do that!

Exercise is an action that the user could perform during the Workout. It will have fields such as Image to illustrate the exercise (field_image, image file), Duration of one repetition in seconds (field_duration, integer) and Default number of repetitions of this exercise (field_reps, integer).

The exercise is a basic item and it will be created by Editor/Staff members on the Drupal side and then the user will be able to select their own from the list in the mobile app.

Create a new Exercise

Paragraph Exercise is an entity that contains reference to Exercise node (field_exercise, entity reference) and Number of repetitions (field_reps, integer) which the user wants to add to their Workout. It’s based on the default value, which the user can change for themselves. Here we will use the Paragraphs module (you need to enable it first) which will help us to create a combined field.

Workout is a list of Paragraph Exercises (field_exercises) that will be saved to the user so the default Number of repetitions is noted. Workout will be created or updated via the API on the app-side, based on the selection by the user, these are Exercises.

Create a new Workout

Now we can get the list of Exercises to show on the mobile side. The URL may contain “fields” parameter to include only the required data of the exercise and the related image. There is more information about fetching data online if you require more detail.

Get a list of Exercises

Note: Remember to get specific data (like Paragraphs) or create/update/delete entities you should be authorized for as a Drupal user, and have the access permissions for that so you can use the request with some Authorization data in header section, like Basic or Bearer token, etc.

Authorization using a Bearer token

So next, we will talk about how to configure and use authorization in Drupal, let’s choose a suitable provider for the authentication.

Authentication method: Basic Auth vs OAuth

This scheme is not considered to be a secure method of user authentication unless used in conjunction with some external secure system such as TLS (Transport Layer Security, [RFC5246]), as the user-id and password are passed over the network as cleartext.

The ‘Basic’ HTTP Authentication Scheme document

Since OAuth 1.0 version is more outdated than OAuth 2.0 we will only speak about OAuth 2.0.

In OAuth, the client requests access to resources controlled by the resource owner and hosted by the resource server, and is issued a different set of credentials than those of the resource owner.
Instead of using the resource owner’s credentials to access protected resources, the client obtains an access token — a string denoting a specific scope, lifetime, and other access attributes.

The OAuth 2.0 Authorization Framework document

Based on the official specifications of these methods, we can see some conclusions about their use in work. Therefore, the choice is clear in favor of OAuth! But can we quickly set up and use it? Yes, the Simple OAuth module will help us.

Before using it we have to restrict the permissions of the default Authenticated user role. However, it may be better to create a new role on the Roles page (/admin/people/roles) for use with API. For example, the Customer role which must be assigned to each new user (which may be a custom code in hook_user_insert() or Registration role module) and has only a limited amount of Permissions, as shown below:

Customer role permissions

Now that the Simple OAuth module is enabled, we can go to the settings page (/admin/config/people/simple_oauth) and generate new credential keys in a private directory outside of your root location (e.g. ../oauth_keys).

Then, go to the ‘Clients’ tab and create a new ‘Consumer’ with CustomerScopes’ as recently added role. Also, fill in the ‘New Secret’ and ‘Is Confidential?’ fields for security reasons.

Create a new Consumer

The description of the module has a lot of information on how to create, refresh or debug tokens.

Get a new Bearer token

We will use the Password Grant to create a Bearer token to get full access to own Workouts during user sign up or log in to the application. After the expiration of the access token, it can be refreshed using Refresh Token Grant.

Perform CRUD operations using API

Great, now we can receive the necessary data of the Workout including related Paragraphs and Exercises with the following request. The full response you can see in the collection Get a Workout data by ID.

Get a Workout data by ID

The important part of the “relationships” response is that the “field_exercies” is an array of paragraph entities related by “id” and “meta” → “target_revision_id” to our Workout. These paragraphs should be created before the new Workout and should be referenced by their “drupal_internal__revision_id” (“target_revision_id”data of workout entity) attribute.

I know that this doesn’t sound as simple as before, but let’s clarify this and try to change workout title and replace ‘Hip flexion’ exercise (paragraph “id” = “b510d08f-8281–4c81–94ff-458754f16cb5”) with a new one.

Like before, we first need to create a new paragraph of exercise by sending a POST request to:


with required headers:

Authorization: Bearer {{access_token}}
Accept: application/vnd.api+json
Content-Type: application/vnd.api+json

and Body with the “id” of the new Exercise node.

  "data": {
    "type": "paragraph--exercise",
    "attributes": {
      "field_reps": "12"
    "relationships": {
      "field_exercise": {
        "data": {
          "type": "node--exercise",
          "id": "a95776ae-d3cc-481e-9f11-3a65c39c4593"
Create a new Paragraph exercise

Paragraph has been created and we have its “id” and “drupal_internal__revision_id” to add to our Workout instead of the previous one, or we can create a new Workout with only this one exercise. Let’s update the existing Workout (“id” = “a2811e66–0c1f-47b5–9d6d-88b651249ee5”) by sending a PATCH request to:


with the same required Headers (Authorization,Accept,Content-Type) and Body with the “Updated workout” title and a new paragraph (“id” = “c0617241–61f3–430d-80ba-4072bfe891e1”and“target_revision_id” = “4”).

  "data": {
    "type": "node--workout",
    "id": "a2811e66-0c1f-47b5-9d6d-88b651249ee5",
    "attributes": {
      "title": "Updated workout"
    "relationships": {
      "field_exercises": {
        "data": [
            "type": "paragraph--exercise",
            "id": "005a5fb1-526f-4a65-98ed-1e729266c08c",
            "meta": {
              "target_revision_id": 1
          }, {
            "type": "paragraph--exercise",
            "id": "b79f5cac-c8b7-4d66-9745-685675a02114",
            "meta": {
              "target_revision_id": 3
          }, {
            "type": "paragraph--exercise",
            "id": "c0617241-61f3-430d-80ba-4072bfe891e1",
            "meta": {
              "target_revision_id": 4

Cool! Your Workout has been updated with the new data.

Update a Workout

To create a new Workout we should send one similar, but without “id” parameter in URL, POST request to:

Create a new Workout

To delete an existing Workout we should send DELETE request with “id” in URL and without Body parameter to:

Delete a Workout

Great! You have learned how to perform any CRUD operations on Drupal entities using API.

Improve performance using subrequests

It all looks good, but there may be some issues with processing of the requests and performance when we need to create a lot of paragraphs for each workout. It can be dozens of consecutive requests to the server and dozens of minutes of waiting when the database can be locked during an operation to create an entity.

Therefore, we should think about improving and combining the requests. How? By using the Subrequests module.

The most amazing feature of this module is the ability to use the replacement tokens, like: {{/<request-id>.body@<json-path>}}.

The replacement data will be extracted from the response to the request indicated by the request ID in the replacement token.

Subrequests module documentation

The next article confirms that and this is what we need to combine the
N-number of requests to a single one.

Before starting we must use the subrequests (/admin/people/permissions#module-subrequests) with this module for the Customer role (or any Roles that you want).

The next example shows how to combine five requests to create Paragraph exercises, then how to create a Workout and get the necessary information regarding it.

Each subrequest for creating a Paragraph entity may contain a “waitFor” parameter of the previous request and an encoded Body with “id” of the exercise node and number of repetitions.

The Authorization header that is used in the main request can be omitted for each subrequest as inherited.

  "requestId": "paragraph-2",
  "waitFor": ["paragraph-1"],
  "action": "create",
  "uri": "/api/json/paragraph/exercise",
  "headers": {
    "Accept": "application/vnd.api+json",
    "Content-Type": "application/vnd.api+json"
  "body": "{\n \"data\": {\n \"type\": \"paragraph--exercise\",\n \"attributes\": {\n \"field_reps\": \"16\"\n },\n \"relationships\": {\n \"field_exercise\": {\n \"data\": {\n \"type\": \"node--exercise\",\n \"id\": \"842f3adb-2399-4196-a07e-e2571db1b0ca\"\n }\n }\n }\n }\n}"

The subrequest for creating a Workout entity must contain a “waitFor” parameter of the last Paragraph request and an encoded Body with “id” and “target_revision_id” of the Paragraph exercise entities, as the replacement tokens from the previous requests (like {{paragraph-1.body@$}} and {{paragraph-1.body@$.data.attributes.drupal_internal__revision_id}}).

  "requestId": "workout",
  "waitFor": ["paragraph-5"],
  "action": "create",
  "uri": "/api/json/node/workout",
  "headers": {
    "Accept": "application/vnd.api+json",
    "Content-Type": "application/vnd.api+json"
  "body": "{\t\n  \"data\": {\n    \"type\": \"node--workout\",\n    \"attributes\": {\n      \"title\": \"Test workout\"\n    },\n    \"relationships\": {\n      \"field_exercises\": {\n        \"data\": [\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-1.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-1.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-2.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-2.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-3.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-3.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-4.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-4.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-5.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-5.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          }\n        ]\n      }\n    }\n  }\n}"

The response could include Body’s that indicate a sub-response and can be decoded as the usual response, more info in module’s documentation.

Create a new Workout using a sub-request

The subrequest for updating the Workout entity must create only a part of the entities that should be changed, the other ones should be placed without any changes.

  "requestId": "workout",
  "waitFor": ["paragraph-2"],
  "action": "update",
  "uri": "/api/json/node/workout/52e0c56c-04bb-4cc6-86b2-357d13cddc0c",
  "headers": {
    "Accept": "application/vnd.api+json",
    "Content-Type": "application/vnd.api+json"
  "body": "{\t\n  \"data\": {\n    \"id\": \"52e0c56c-04bb-4cc6-86b2-357d13cddc0c\",\n    \"type\": \"node--workout\",\n    \"attributes\": {\n      \"title\": \"Test workout\"\n    },\n    \"relationships\": {\n      \"field_exercises\": {\n        \"data\": [\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-1.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-1.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"{{paragraph-2.body@$}}\",\n            \"meta\": {\n                \"target_revision_id\": \"{{paragraph-2.body@$.data.attributes.drupal_internal__revision_id}}\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"7e2b674f-6e04-4246-9b60-78df1acc8d29\",\n            \"meta\": {\n                \"target_revision_id\": \"6\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"66d7d0c5-955e-48df-a896-4c1d05390255\",\n            \"meta\": {\n                \"target_revision_id\": \"7\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"b06c9110-1f3f-417f-b0bc-ef9e452acd31\",\n            \"meta\": {\n                \"target_revision_id\": \"8\"\n            }\n          },\n          {\n            \"type\": \"paragraph--exercise\",\n            \"id\": \"5e80b3ca-a191-4405-a8da-78de2d676262\",\n            \"meta\": {\n                \"target_revision_id\": \"9\"\n            }\n          }\n        ]\n      }\n    }\n  }\n}"
Update Workout using a sub-request


So, the JSON:API module that was committed to Drupal 8.7 core shows that Drupal is quickly growing as an API-first system. Using the OAuth 2.0 authentication allows us to perform any operations on entities and provides great data security. And using the subrequests to combine about 6–7 requests into a single one, we were able to increase the performance of creating/editing complex entities from about 4.1 to 1.16 seconds, which is more than 250%!

Sounds awesome, doesn’t it? 🙂

Well, now you know much more about Drupal as an API-first system and its capabilities. I hope this information will be useful to you in the future.

Thanks for reading and I wish you good luck with Drupal! 🙂

(Visited 3,130 times, 2 visits today)
Last modified: June 1, 2021
Author info
Sergey Linkin
Sergey is somewhat of a unicorn of the tech world: a full-stack web developer of the highest calibre. He works with databases, CMS frameworks, config/packages management and much more — he’s like 5 or 6 people rolled up into one Brainiac!