I’ve long been curious about building a decoupled (headless) Drupal setup: using Drupal solely as the backend for content modeling, storage, and administration; exposing data through JSON:API or REST endpoints; and rendering the frontend with a JavaScript framework like Next.js or Nuxt.js to leverage component-based development and consume the API.

I hadn’t found the motivation to start… (I though I have to write a great amount of custom code myself) until I discovered Astro, whose documentation provides a clear, step-by-step guide to this approach. This article largely follows that guide. For the original source, see: https://docs.astro.build/en/guides/cms/drupal/.

Step-1: Drupal (as Backend) Setup

We need to install Drupal using DDEV on your local, personally I prefer used this code snippet: drupal-ddev-init-snippet, and enable the following core modules: “JSON:API” and “Serialization”, then create a new “Article” content, as shown below:

2026-04-16T094527

Once the above is done, you should be able to visit the following JSON:API’s URL: https://base-url/jsonapi/node/article, either using your browser like Chrome or API development tool like Postman, and find the JSON formatted print-out with the article you just created:

2026-04-16T094807

Note that the official documentation of Astro + Drupal mentioned that you should setup credentials such as “Basic Authentication” or “API Key-based authentication” (link). In this tutorial, we’ll focus on the important part, so the data-fetching authentication is skipped intentionally for the sake of simplicity. But if you intend to deploy your application to any production environment, it is strongly recommended that you do not skip this step.


Step-2: Astro (as Frontend) Setup

We need to initialise an Astro project using the npm create astro@latest command, and choose minimal (empty) as the template. Once initialised, we’ll install the below dependencies that we will be using later:

  • JSONA: JSON API v1.0 specification serializer and deserializer for use on the server and in the browser.
  • Drupal JSON-API Params: This module provides a helper Class to create the required query. While doing so, it also tries to optimise the query by using the short form, whenever possible.

2026-04-16T095901

Then setup data fetching from Astro:

  • First, add environmental variable DRUPAL_BASE_URL to be used later by creating an .env file in the root directory of the Astro project:

    1
    2
    
    # FILE: astro-project-root/.env
    DRUPAL_BASE_URL="http://ddev-drupal-astro--backend--basic.ddev.site/"
    
  • Second, modify the src/pages/index.astro with the following:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    # FILE: astro-project-root/src/pages/index.astro
    ---
    import {Jsona} from "jsona";
    import {DrupalJsonApiParams} from "drupal-jsonapi-params";
    import type {TJsonApiBody} from "jsona/lib/JsonaTypes";
    
    // Get the Drupal base URL
    export const baseUrl: string = import.meta.env.DRUPAL_BASE_URL;
    
    // Generate the JSON:API Query. Get all title and body from published articles.
    const params: DrupalJsonApiParams = new DrupalJsonApiParams();
    params.addFields("node--article", [
            "title",
            "body",
        ])
        .addFilter("status", "1");
    // Generates the query string.
    const path: string = params.getQueryString();
    const url: string = baseUrl + '/jsonapi/node/article?' + path;
    
    // Get the articles
    const request: Response = await fetch(url);
    const json: string | TJsonApiBody = await request.json();
    // Initiate Jsona.
    const dataFormatter: Jsona = new Jsona();
    // Deserialise the response.
    const articles = dataFormatter.deserialize(json);
    ---
    <body>
     {articles?.length ? articles.map((article: any) => (
        <section>
          <h2>{article.title}</h2>
          <article set:html={article.body.value}></article>
        </section>
     )): <div><h1>No Content found</h1></div> }
    </body>
    
  • Finally, run npm run dev to start a Astro development server, and open the “Local” server as denoted on the CLI in your browser:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    > npm run dev
    
    > dev
    > astro dev
    
    [vite] connected.
    10:11:41 [types] Generated 0ms
    10:11:41 [WARN] [content] Content config not loaded
    10:11:41 [vite] Re-optimizing dependencies because vite config has changed
    10:11:41 [vite] Port 4321 is in use, trying another one...
     astro  v6.1.7 ready in 359 ms
     Local    http://localhost:4322/
     Network  use --host to expose
    10:11:41 watching for file changes...
    10:11:42 [200] / 88ms
    10:11:42 [vite]  new dependencies optimized: astro/runtime/client/dev-toolbar/entrypoint.js
    10:11:42 [vite]  optimized dependencies changed. reloading
    
  • Final Outcome: 2026-04-16T101654

(Note that:

  • if you accidentally used https:// instead of http:// in your .env file, you may encounter fetch error of error code UNABLE_TO_VERIFY_LEAF_SIGNATURE. This is because, by default, a locally hosted Drupal website using DDEV does not provide a Certificate Authority (CA) certificate, which means the node server is unable to verify that the Drupal site’s SSL/TLS certificate is authentic: 2026-04-16T101330

  • by default, if you happen to find any encoding issue with your page (e.g. don't shown as don’t), you can fix it via adding meta tag to the index.astro file:

    2026-04-16T102029

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    ...
    + <meta charset="UTF-8">
    <body>
     {articles?.length ? articles.map((article: any) => (
        <section>
          <h2>{article.title}</h2>
          <article set:html={article.body.value}></article>
        </section>
     )): <div><h1>No Content found</h1></div> }
    </body>
    

Step-3: Add Tailwind CSS to Astro

For simplicity of styling tasks later, I choose to add Tailwind CSS, along with tailwindcss-typography and tailwindcss-form to the project, using this guide: https://docs.astro.build/en/guides/styling/#tailwind

Execute the following:

1
2
3
npx astro add tailwind
npm install -D @tailwindcss/forms
npm install -D @tailwindcss/typography

Create new global.css file, and source it from the index.astro entry point:

1
2
3
4
/* FILE: src/styles/global.css (new) */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# FILE: src/pages/index.astro
+ import "../styles/global.css";
import {Jsona} from "jsona";
...
<meta charset="utf-8" />
- <body>
+ <body class="prose container px-4">
    {articles?.length ? articles.map((article: any) => (
        <section>
          <h2>{article.title}</h2>
-         <article set:html={article.body.value}></article>
+         <article set:html={article.body.value} class="border-2 rounded-sm p-4"></article>
        </section>
     )): <div><h1>No Content found</h1></div> }
</body>

Refresh the page, then you’ll get some quick wins of some very basic styling:

2026-04-16T103117


Reference & Extension

  • Astro Docs – Drupal & Astro: link
  • Drupal Docs – API Authentication: link
  • NPM – Jsona (serializer and deserializer): link
  • NPM – Drupal JSON-API Params (defines standard query parameters): link