a991be3e-f8f1-4fbf-b576-6988a4ae8b68

Executive Summary

This guide demonstrates how to build a decoupled Drupal site using Astro as the frontend. By separating backend content management from the presentation layer, you can leverage Drupal’s robust modeling while utilizing modern JavaScript frameworks for a faster, component-based user experience.

Step 1: Backend Setup (Drupal)

  • Environment: Install Drupal using DDEV.Modules:
  • Enable JSON:API and Serialization core modules.
  • Content: Create a sample “Article” and verify the output at https://[base-url]/jsonapi/node/article.

Step 2: Frontend Setup (Astro)

  • Init: Run npm create astro@latest using the “empty” template.
  • Dependencies: Install jsona (for deserializing) and drupal-jsonapi-params (for query optimization).
  • Configuration: Define your DRUPAL_BASE_URL in a .env file.
  • Fetching: Use fetch() in the component frontmatter to retrieve and deserialize Drupal data.

Step 3: Styling and Modularity

  • Styling: Add Tailwind CSS with typography and forms plugins to speed up UI development.
  • Refactoring: Separate data fetching logic into utility files (e.g., fetchNodeArticle.ts) and create reusable Astro components for rendering.

Step 4: Dynamic Content with Server Islands

  • Astro components are static by default. To ensure Drupal content updates reflect without a full site rebuild:

  • Add a Server Adapter (e.g., @astrojs/node).Apply the server:defer directive to your article component.This renders the component on-demand while serving the rest of the page as static HTML.

Step 5: Integrating UI Frameworks (Vue + Nuxt UI)

  • Astro is framework-agnostic, allowing you to use multiple frameworks simultaneously.

  • Vue: Add the Vue integration via npx astro add vue.

  • Nuxt UI: Install dependencies and register the Nuxt UI plugin in a custom entry point (e.g., vue-app.ts) to enable global component access.

Step 6: Client-Side Interaction

  • For interactive elements or client-side fetching:

  • Selective Hydration: Use the client:load directive to hydrate components immediately on page load.

  • Decoupled Menus: Fetch Drupal’s “Main Navigation” using the linkset endpoint: ${baseURL}/system/menu/[menu-name]/linkset.

  • Optimisation: Consider client:visible to delay hydration until the component enters the viewport, further improving performance.


Detailed Steps Breakdown

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: Style Enhancement & Refactoring

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

Further more, for better modularity and readability, I decide to separate the “fetch data” and “rendering” logic into their own utility and component files: src/utils/fetchNodeArticle.ts , src/components/article.astro :

2026-04-17T104527

Step-4: Server Islands (server:defer directive)

Note that in the previous step, when you run npm run dev (OR astro dev) you are using development mode to preview the website. Under this mode, you will always be able to see an up-to-update version of your website, which means no matter if a component is a regular component, or server island it will always be re-rendered on every page load.

However, if you use the deployment setting: npm run build && npm run preview (OR astro build && astro preview), you will preview the version of your site that will be created at build time. This means the majority of the astro component (that are not server island, and in our case, are not JavaScript framework component) will be converted to fast, static HTML.

2026-04-17T104841

For our use case, this may be troublesome, because we’re fetching from the Drupal JSON:API as the source of data/content, …. See below example, when you use npm run build && npm run preview to serve the page, modification to the content in Drupal backend will not impact the astro pages, this because they are served in HTML after the build process!

2026-04-17T105105

To solve this, we’ll need to make this component an “Server Island”, when you turn a regular Astro component into a server island, instead of the component being transformed into plain HTML, it will be rendered on demand (on server) on page load (on client).

To make our <Article> component server island we’ll use server:defer directive, but before we do that we’ll need to add a server adapter (allows you to trigger code execution on Node/Vercel/etc server to generate page on demand), read more about this on this page: https://docs.astro.build/en/guides/on-demand-rendering/#server-adapters. For our local development example, I’ll use @astrojs/node:

  • install dependency npx astro add node

  • modify the astro.config.mjs file:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
      // @ts-check
      import { defineConfig } from 'astro/config';
    + import node from '@astrojs/node';
      import tailwindcss from '@tailwindcss/vite';
    
      // https://astro.build/config
      export default defineConfig({
    +    adapter: node({
    +        mode: 'standalone'
    +      }),
          vite: {
            build: {
              sourcemap: true, // or 'hidden' to avoid exposing them publicly
            },
            plugins: [tailwindcss()],
          },
      });
    

Finally we can add the server:defer directive to our astro component at src/page/index.astro

1
2
3
4
5
6
7
8
9
 ---
 import Article from "../components/article.astro";
 import "../styles/global.css";
 ---
 <meta charset="utf-8" />
 <body class="prose container px-4">
-    <Article/>
+    <Article server:defer/>
 </body>

Now re-build the page and preview, also disable the javascript then inspect the page, you’ll see that instead of a static HTML component, it become a script tag with data-island-id, that will be replaced with server side rendered <Article> via replaceServerIsland(); And when we change the content in Drupal it changes with it:

2026-04-17T110032

2026-04-17T110232

Step-5: Astro + UI Frameworks + Component Library

With Astro you can bring any UI framework onboard, whether it is React, Vue, or Alpine. What’s even better is that Astro supports having multiple framework in the same project, each island can use its own framework, and Astro will render each one based on its file type and integration setup.

Find out more about this in the following links

In the below example, we’ll try to bring Vue and Nuxt UI library onboard.

Step-5.a: Installing and Create Vue Component

Use the astro add command to install vue:

1
npx astro add vue

You’ll be prompted to configure the relevant files:

2026-04-20T161045

(* as shown above, the astro add command will automatically edit the astro.config.mjs file to add the vue integration)

Creating a counter vue component under the src/component folder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- FILE: src/components/Counter.vue -->
<script setup>
import { ref } from 'vue';
const count = ref(0);
const increment = () => {count.value++; return}
const decrement = () => {count.value--; return}
</script>

<template>
  <div>
    <button @click="decrement" class="hover:cursor-pointer mx-2">-</button>
    <span class="text-2xl font-bold">{{ count }}</span>
    <button @click="increment" class="hover:cursor-pointer mx-2">+</button>
  </div>
</template>

And modify the src/pages/index.astro entry point file to use this UI framework component:

1
2
3
4
5
6
7
8
---
import Counter from "../components/Counter.vue";
import "../styles/global.css";
---
<meta charset="utf-8" />
<body class="container px-4 py-8">
    <Counter client:load />
</body>

Final outcome:

2026-04-20T161624

Step-5.b: Installing Nuxt UI Component Library

I’m following this official Nuxt UI Installation Guide: link to perform this step.

First run the npm command to install the required dependencies:

1
npm install @nuxt/ui tailwindcss

Then modify the astro.config.mjs file to use Nuxt UI’s vite plugin:

 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
    import { defineConfig } from 'astro/config';
    import node from '@astrojs/node';
    import tailwindcss from '@tailwindcss/vite';
    import vue from '@astrojs/vue';
+   import ui from '@nuxt/ui/vite';

    // https://astro.build/config
    export default defineConfig({
      adapter: node({
          mode: 'standalone'
        }),

      vite: {
        build: {sourcemap: true},
-       plugins: [tailwindcss()],
+       plugins: [tailwindcss(), ui()],
      },
      integrations: [
-       vue()
+       vue({
+         jsx: true,
+         appEntrypoint: './src/vue-app.ts',
+       }),
      ],
    });

And also create the entry point file src/vue-app.ts (Purpose: provide a place to register vue plugin before vue component are mounted by Astro, in our case we need to globally install the Nuxt UI plugin so that all Vue component can use component and features from @nuxt/ui)

1
2
3
4
5
6
// FILE: src/vue-app.ts
import type { App } from 'vue';
import ui from '@nuxt/ui/vue-plugin';
export default async function setup(app: App) {
  app.use(ui);
}

Finally test creating a component that uses the Nuxt UI PageCard:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- FILE: src/components/NuxtUIPageCard.vue -->
<script setup>
import UPageCard from "@nuxt/ui/components/PageCard.vue";
</script>
<template>
    <div class="space-y-6">
        <UPageCard
            title="Tailwind CSS"
            description="Nuxt UI integrates with latest Tailwind CSS, bringing significant improvements."
            icon="i-simple-icons-tailwindcss"
            orientation="horizontal"
            spotlight
            spotlight-color="error"
        >
            <img
               src="https://ui.nuxt.com//tailwindcss-v4.svg"
               alt="Tailwind CSS"
               class="w-full"
            />
        </UPageCard>

    </div>
</template>

And modify the src/pages/index.astro entry point file:

1
2
3
4
5
6
7
8
9
---
import NuxtUIPageCard from "../components/NuxtUIPageCard.vue";
import "../styles/global.css";

---
<meta charset="utf-8" />
<body class="container px-4 py-8">
    <NuxtUIPageCard client:load />
</body>

Final Outcome:

2026-04-20T162520

Step-6: Client Island (using client:load directive)

Unlike in the previous example where the component is rendered as a server island (using server:defer directive), since we’re using a client-side framework – Vue, if we need to write the fetching logic inside Vue’s <script>, we’ll need a client side referring component, and in order to do that, we will add client:load to the Astro component.

Step-6a: update fetching logic

But first, let’s modify the data fetching logic, since we’re now fetching the data from the client side, we need to write a proxy router in Vite, such that all the API calls initiated from the Vue components goes to the same domain (no CROS issue) →modify astro.config.mjs

 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
37
38
39
40
41
42
43
// @ts-check
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import tailwindcss from '@tailwindcss/vite';
import vue from '@astrojs/vue';
import ui from '@nuxt/ui/vite';
import { loadEnv } from 'vite';

+ function get_baseUrl() {
+    const viteMode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
+    const env = loadEnv(viteMode, process.cwd(), '');
+    const drupalTarget = (env.PUBLIC_DRUPAL_BASE_URL || '').replace(/\/+$/, '');
+    return drupalTarget;
+ }

export default defineConfig({
    devToolbar: { enabled: false },
    adapter: node({
        mode: 'standalone',
    }),
    integrations: [
        vue({
        jsx: true,
        appEntrypoint: './src/vue-app.ts',
        }),
    ],
    vite: {
        build: { sourcemap: true },
        plugins: [tailwindcss(), ui({ router: false })],
+        server: {
+           proxy: get_baseUrl()
+            ? {
+                '/__drupal': {
+                target: get_baseUrl(),
+                changeOrigin: true,
+                secure: false,
+                rewrite: (path) => path.replace(/^\/__drupal/, '') || '/',
+                },
+            }
+            : {},
+        },
    },
});

And in Astro, in order for a environmental variable to be browser/client-side visible, it must be prefixed with PUBLIC_ → modify .env

1
2
- DRUPAL_BASE_URL="http://ddev-drupal-astro--backend--basic.ddev.site"
+ PUBLIC_DRUPAL_BASE_URL="http://ddev-drupal-astro--backend--basic.ddev.site"

Lets extract the logic of article api fetching into a utility file: src/utils/fetch_data.ts:

 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
37
38
39
40
import {Jsona} from "jsona";
import {DrupalJsonApiParams} from "drupal-jsonapi-params";
import type {TJsonApiBody} from "jsona/lib/JsonaTypes";

function resolve_baseURL(): string {
    // Check if the Drupal base URL is set in the environment variables.
    if (!import.meta.env.PUBLIC_DRUPAL_BASE_URL) {
        console.warn("[fetchNodeArticle] Set DRUPAL_BASE_URL in your .env (e.g. https://project.ddev.site)");
        throw new Error("DRUPAL_BASE_URL is not set in your .env");
    }
    // Check if the component is running on the client side.
    const is_client_side_component = (typeof globalThis !== "undefined" && "location" in globalThis);
    // If the component is running on the client side and in development mode, use the Vite proxy.
    if (import.meta.env.DEV && is_client_side_component) {return "/__drupal";}
    // Otherwise, use the Drupal base URL.
    else {return import.meta.env.DRUPAL_BASE_URL;}
}


export const fetchNodeArticle = async () => {
    const baseURL = resolve_baseURL();

    // 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");
    const path: string = params.getQueryString();
    const url: string = `${baseURL}/jsonapi/node/article?${path}`;

    // Get the articles from the Drupal JSON API
    const request = await fetch(url);
    if (!request.ok) {throw new Error(`JSON:API request failed: ${request.status} ${request.statusText}`,);}
    const json: string | TJsonApiBody = await request.json();

    // Initiate Jsona.
    const dataFormatter: Jsona = new Jsona();
    // Deserialise the response.
    const articles = dataFormatter.deserialize(json);

    return articles;
}

Step-6b: Setup Client Island (using client:load directive)

Here’s the component itself at src/components/article-card-nuxt-ui.vue:

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<script setup>
import { reactive, onMounted } from "vue";
import UPageCard from "@nuxt/ui/components/PageCard.vue";
import { fetchNodeArticle, fetchMainNavigationMenu } from "../utils/fetch_data";
const articles = reactive([]);
onMounted(async () => {
    try {
        const data = await fetchNodeArticle();
        const list = (Array.isArray(data) ? data : (data != null ? [data] : []));

        // Strip HTML tags and truncate `body.value` of each article (if present)
        for (const item of list) {
            if (item.body && typeof item.body.value === 'string') {
                const parser = new DOMParser();
                const doc = parser.parseFromString(item.body.value, 'text/html');
                let textContent = doc.body.textContent || "";
                if (textContent.length > 100) {
                    textContent = textContent.slice(0, 200) + '…';
                }
                item.body.value = textContent;
            }
        }
        articles.splice(0, articles.length, ...list);
    } catch (err) {
        console.error("[article-card-nuxt-ui] Failed to fetch articles", err);
    }
});
</script>
<template>
    <!-- No Content found -->
    <div v-if="!articles || articles.length === 0">
        <UPageCard
        title="No Content found"
        description="Pending to load content from Drupal API"
        icon="i-lucide-lightbulb"
        spotlight
        spotlight-color="primary"
        :ui="{root: 'mb-5 w-full'}"
    >
    </UPageCard>
    </div>

    <!-- Content found -->
    <div v-if="articles && articles.length > 0">
        <UPageCard
            v-for="(article, index) in articles"
            :key="index"
            :title="article.title"
            icon="i-lucide-lightbulb"
            spotlight
            spotlight-color="primary"
            :ui="{root: 'mb-5 w-full'}"
        >
            <template v-if="article.body?.value" >
                <div v-html="article.body.value"  />
            </template>
        </UPageCard>
    </div>
</template>

Import it and use it in the src/pages/index.astro:

1
2
3
4
5
6
7
8
---
import ArticleCardNuxtUi from "../components/article-card-nuxt-ui.vue";
import "../styles/global.css";
---
<meta charset="utf-8" />
<body class="px-4 w-full">
    <ArticleCardNuxtUi client:load />
</body>

Final Outcome of this in browser:

2026-04-23T115943

Step-6c: different client:* directive

Note that for the above instance, when using client:load directive, you’re using selective hydration; Astro will render the component on the server, and the component is hydrated on the client immediately when the page load. If you disable the JavaScript you’ll see the “No Content Found” card (rendered by Astro during the build time):

2026-04-23T120106

You may also use client:visible to make the component render when it becomes visible in the viewport:

2026-04-23T122048

Or use client:only={vue} to skip the server rendered HTML, and renders only on the client:

2026-04-23T122312

Step-6d: More Component – Main Navigation

In drupal you can enable decoupled menu setting (see: https://www.drupal.org/docs/develop/decoupled-drupal/decoupled-menus/decoupled-menus-overview) then you will also be able to fetch your menu settings through JSON:API: ${baseURL}/system/menu/${menu_machine_name}/linkset (e.g. for “Main Navigation” menu it is: ${baseURL}/system/menu/main/linkset)

2026-04-23T122731

Create the additional fetching logic in src/utils/fetch_data.ts:

 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
export const fetchMainNavigationMenu = async () => {
    const baseURL = resolve_baseURL();
    const url: string = `${baseURL}/system/menu/main/linkset`;
    const request = await fetch(url);
    if (!request.ok) {throw new Error(`JSON:API request failed: ${request.status} ${request.statusText}`,);}
    const json: string | TJsonApiBody = await request.json();

    // parse the json response to a javascript object
    let menu_data:NavigationMenuItem[]= [];
    const linkset_items = json.linkset[0].item;
    for (const linkset_item of linkset_items) {
        let parent_menu:NavigationMenuItem[] = menu_data;
        for (let i = 0; i < linkset_item.hierarchy.length; i++) {
            if(i == (linkset_item.hierarchy.length - 1)){
                parent_menu.push({
                    label: linkset_item.title,
                    to: linkset_item.href,
                    children: [],
                });
            } else {
                const hierarchy_idx = linkset_item.hierarchy[i];
                parent_menu = parent_menu[hierarchy_idx].children as NavigationMenuItem[];
            }
        }
    }
    return menu_data;
}

And create the component src/components/main-menu.vue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<script setup>
    import { fetchMainNavigationMenu } from "../utils/fetch_data";
    import {reactive, onMounted} from "vue";
    const menu = reactive([]);
    onMounted(async () => {
        const menu_data = await fetchMainNavigationMenu();
        menu.splice(0, menu.length, ...menu_data);
    });
</script>
<template>
    <UNavigationMenu :items="menu" :ui="{root: 'w-full my-3'}"/>
</template>

And used it in src/pages/index.astro:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
---
import ArticleCardNuxtUi from "../components/article-card-nuxt-ui.vue";
import MainMenu from "../components/main-menu.vue";
import "../styles/global.css";

---
<meta charset="utf-8" />
<MainMenu client:load />
<body class="px-4 w-full mt-10">
    <ArticleCardNuxtUi client:only="vue" />
</body>

Final outcome:

2026-04-23T123014

Step-6d: More Component – Paginated Article

Source Code: example-article-with-pagination.zip

2026-04-23T124400


Reference & Extension

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