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:

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:

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.

Then setup data fetching from Astro:
First, add environmental variable
DRUPAL_BASE_URLto be used later by creating an.envfile 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.astrowith 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 devto 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. reloadingFinal Outcome:

(Note that:
if you accidentally used
https://instead ofhttp://in your.envfile, you may encounterfetch errorof error codeUNABLE_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:
by default, if you happen to find any encoding issue with your page (e.g.
don'tshown asdon’t), you can fix it via adding meta tag to theindex.astrofile:
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:
| |
Create new global.css file, and source it from the index.astro entry point:
| |
| |
Refresh the page, then you’ll get some quick wins of some very basic styling:
