Building a Nuxt 3 starter app with headless Drupal 10

Steve HamSteve Ham
14 min read

Headless CMSes have become increasingly popular lately in no small part thanks to the relatively recent rise of meta frameworks like Next.js (and Nuxt.js), Gatsby, and Astro which can all take over the front-end duties that a CMS would normally do. While Next.js with headless WordPress is very well-documented at this point because of their widespread adoption together, the Vue-based Nuxt.js with headless Drupal is a particular combo that seems to be less documented, particularly in their latest versions as of this writing (Nuxt 3 and Drupal 10), so in this post I'll provide a tutorial on how to set them up and begin using them together.

Prerequisites: I'm going to assume that you have working familiarity with HTML, CSS, JavaScript, and Node.js, know your OS (whether that's Windows, macOS, or Linux) well enough to be able to manually install software on it, and also know your way around using VS Code.

Setting up Drupal as an API

  1. Download & install Drupal 10 (https://www.drupal.org/download)

Note that the main prerequisites before installing Drupal include a web server (such as Apache or nginx) and MySQL (or equivalently MariaDB), so if you don't already have these installed, installation instructions can be found via Google. Once you have a web server and MySQL installed, installing Drupal is fairly straightforward and just involves entering the MySQL database and user account info, along with some of the site info. Side-note: in a pinch you can actually use PHP's built-in web server to serve up Drupal as well. But it's far better to use Apache or nginx.

  1. Once Drupal is installed, open your Web browser and load the Drupal site (for example http://localhost:80/), and login to access the site administration using the credentials you created during setup.

If the initial page on Drupal looks completely unstyled (i.e. the CSS doesn't seem to have any effect), go to Configuration > Performance and uncheck "Aggregate CSS files" and "Aggregate JavaScript files", then click on "Save configuration". (This has been an issue that has shown up in some recent versions of Drupal.)

  1. Modules in Drupal are the same concept as plugins in WordPress, which means that they can provide unique functionality and capability to a site, completely depending on the intent of the developer(s) who made them. Drupal provides a few modules "out of the box" that you have to enable to get functional support for creating your own REST API endpoints that can be consumed from an external front-end.

Once you're logged in, under the "Extend" menu, scroll down to "Web services" and enable "RESTful Web Services", "JSON:API", and "Serialization", then click Install. These three modules install support in Drupal to create your own REST API endpoints (and aren't installed by default, for whatever reason).

  1. In Drupal, normally views are used to create customized lists of content that you can place (almost) anywhere on the site, but in our instance we can create a view that will serve as our first API endpoint.

Under the Structure menu, click Views, then the "+ Add view" button. You can name the "View name" anything you want here, but "REST View" can be a good name to use. At the bottom, check the box for "Provide a REST export", enter "/api/content" into the input field (which will be the actual endpoint), then click "Save and edit". You can alternately enter "/api/articles" in the input field, or any other name, to denote a different type of content as desired.

On the next screen, under Contextual filters (which is under Advanced, if you need to expand that), click Add, and you can add whatever you want to search by under this endpoint. In most circumstances, this will probably be the content ID, in which case you can enter "ID" in the search box and select the ID for Content. You can alternately use any of the other provided filters, including "Has taxonomy term ID" to use the IDs corresponding to any taxonomy terms entered in Drupal. If you want to alternately be able to use taxonomy terms by their name, you can download & install the Drupal module Views taxonomy term name into ID.

Adding content and configuring Drupal

  1. At this point, you might want to add some usable content that can be consumed from the Nuxt front-end. Under the top navigation, click Content -> "+ Add content" -> Article, and repeat this process a few times to add some content.

For the sake of simplicity, one example I used on my own demo site was music albums, in which case you could add the following as content articles: OneRepublic - Dreaming Out Loud, OneRepublic - Native, Phantogram - Eyelid Movies, and Phantogram - Ceremony, using the album titles for the article titles, and the band names as the tags (adding band names as the tag will have an advantage as we'll see later when building the front-end).

  1. At this point Drupal is now set up as your REST API! To test it out, you can bring up another browser tab and point it to this URL: http://localhost:80/api/content/1?_format=json (changing the port as appropriate if needed, along with "content" if you named that something else).

Note the number "1" in the URL (although in my screenshot it's 3, because I added a couple of other terms beforehand), which denotes the taxonomy term ID, which effectively refers to the Contextual filter that you entered in the view. Or it might denote the content ID, if you used that instead. In either case, note whichever filter you used for future reference. The "_format=json" at the end of the path tells Drupal to give you the result as JSON, which is important because it'll likely run out of memory otherwise (you can try it without that part to see the error message).

  1. One last thing to do in Drupal is to enable CORS, at least for demo purposes since we'll be creating the Nuxt app on localhost as well. Because when we run both Drupal and Nuxt on localhost, they're going to have to run on separate ports, but in general since an app running on one port isn't allowed to access an app running on another port since that's a violation of security, we'll need to enable CORS on Drupal's side.

To do that, go to Configuration -> Web services -> Cross-Origin Resource Sharing (CORS), and check the box at the top to enable it. Inside the input fields for Allowed headers, Allowed methods, and Allowed origins, enter the asterisk "*" (without the quotes), then click Save configuration. Very important to note that normally you should NOT allow everything (i.e. which is what the asterisk means) to access Drupal because that would be a huge security risk. In the eventual outcome when you might want to deploy Drupal and Nuxt for production, you should limit all of these inputs to very specific actions and hosts, and only specify the host running the Nuxt app in production in the Allowed origins.

Building the Nuxt 3 front-end

We now have Drupal set up, but how about the Nuxt app? Let's go ahead and start building it out.

  1. Open your terminal/shell to follow the Nuxt installation instructions that are provided on the Nuxt site.

Note that "npx" is now included with the latest version of Node.js, so if running npx doesn't work, you may need to upgrade your Node.js/npm installation. You can give the Nuxt app any name you want, but for demo purposes I simply called mine "n3_app". Once the project directory is created, open it with VS Code, and "cd" into it.

  1. As good as Nuxt's documentation is, it doesn't explicitly tell you how to get started creating an app from scratch to build components into a slot-based layout, including pages, so we're going to do that first before we get the Nuxt server up and running.

Create "components", "layouts", and "pages" in the project root directory as shown below in the screenshot. Components can helpfully act as a header and footer (or some other visual container that you might want to add in a complex UI), layouts can help to separate your UI appropriately using slots with components, and pages in Nuxt can act as your "static" pages which will typically store your more static content (informational pages like About).

For more info on components, layouts, and pages, the Nuxt documentation provides some helpful info, where you can explore the project directory tree, starting with components: https://nuxt.com/docs/guide/directory-structure/components

  1. It also makes sense to create some new blank files in these directories before spinning up the server. Create the files shown below inside the directories as well:
  • /components/AppFooter.vue

  • /components/AppHeader.vue

  • /layouts/default.vue

  • /pages/about.vue

  • /pages/onerepublic.vue (or whatever other content tag name you might've alternately used, one page per tag)

  • /pages/phantogram.vue

  1. You can now spin up the server using "npm run dev", and point your browser to http://localhost:3000/ where we'll see the default new app screen.

  2. Since Nuxt 3 supports adding the HTML title and meta tags right in the /app.vue file (in the project root directory), we'll go ahead and do that first and add the HTML boilerplate for the new app. Copy the below code into the file, overwriting to replace the content that's initially there, and you'll see the document title get updated. The Nuxt documentation has some helpful info on layouts and how they work on its Layouts page where you'll see that the <NuxtLayout> element defines the layout area, and the <NuxtPage> element defines the page area, which will come from the files in the /pages directory.

<template>
  <div>
    <Head>
      <Title>Demo Site</Title>
      <Meta name="description" content="demo site for Nuxt 3 with headless Drupal" />
      <Meta name="keywords" content="nuxt, vue, drupal, headless, CMS" />
    </Head>
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>

It's considered conventional in Nuxt (and Vue by extension) to have a single "root" element inside the <template> element. This is normally a <div> element but doesn't have to be, and can alternately be a <section> or other semantic HTML element like <main> or <article>.

  1. Since we're not going to have multiple layouts and only need one, we'll go ahead and use the default layout which will be in /layouts/default.vue, as mentioned in the Nuxt documentation. Right now that file is blank, but copying in the below code will fill in a somewhat standard template using the AppHeader and AppFooter files that we previously made back in step 3, and assign a dynamic slot component to everything in between.
<template>
  <div>
    <AppHeader />
    <slot />
    <AppFooter />
  </div>
</template>

<style>
body {
  background: #efefef;
  font-family: Arial, Helvetica, sans-serif;
}
.album {
    border: 1px solid grey;
    margin-bottom: 1em;
    padding: 1em;
}
</style>

This file is also where we can assign some base CSS styles to apply app-wide, much like if you wanted to apply some CSS styles on the <body> tag in a static HTML site to take global precedence over more specific rules for particular elements. So it's a good place to set things like the font and background color. I've also added a CSS class called "album" that'll be used later, on the pages showing the albums to help add some visual separation between them.

  1. Our Nuxt app is still looking very blank in the browser at the moment so we'll rectify this by finally adding something visible to the AppHeader component in /components/AppHeader.vue. As is customary for most web apps that have a header component, we'll show a title and some site-wide navigation to make it easy for users to get around.
<template>
  <div class="nav">
    <h1>NuxtApp</h1>
    <span><NuxtLink to="/">home</NuxtLink>
    | <NuxtLink to="/about">about</NuxtLink>
    | <NuxtLink to="/onerepublic">OneRepublic</NuxtLink>
    | <NuxtLink to="/phantogram">Phantogram</NuxtLink>
    </span>
  </div>
</template>

<style scoped>
.nav {
  margin-bottom: 1em;
}
</style>

<NuxtLink> is Nuxt's way of adding internal links to your pages which it'll reference from the filename prefixes from the files in the /pages directory, so you should generally use these over the traditional <a href=""></a> type of link whenever doing internal page links (it's fine to use the <a> tag for external links, however). Of course these links won't show any actual content yet since we haven't added that, but you will see the navigation jump around on the blank pages that we created earlier. Some bottom margin has been added inside the <style> tag to help the bottom of the navigation look properly spaced above the page content; "scoped" inside the <style> tag means that the styles will apply to this component only and won't follow typical CSS cascade rules.

  1. Next, we'll add something to the AppFooter component in /components/AppFooter.vue as well. Normally on most web apps you'll see things like copyright information and links to additional information in a footer, but in lieu of that we'll just settle for a copyright symbol with the year (it's possible to generate the year dynamically through JavaScript but we'll skip that here).
<template>
  <div>
    <p>&copy; 2024</p>
  </div>
</template>
  1. In the /pages directory, we'll start with the About page in /pages/about.vue. Nothing extensive, just something to serve as temporary page content.
<template>
  <section>
    <p>some text about this website</p>
  </section>
</template>
  1. Our pages for the content being pulled dynamically from our Drupal REST API is where things finally get interesting! The first thing we have to do is make a call to our API endpoint. If you look back at step 6 under the previous section "Setting up Drupal as an API", we're going to want to make a call to this URL: http://localhost:80/api/content/1?_format=json.
<script setup lang="ts">
const { data: results } = await useFetch('http://localhost:80/api/content/1?_format=json', {
  server: false
});
</script>

So let's start by doing exactly that at the top of the file (in my example /pages/onerepublic.vue). Nuxt gives us a built-in useFetch() method that we can use to make API calls, which has to be prefaced with await, since it's an asynchronous method. While it normally returns five values (data, status, error, refresh, and clear) and in a production app you should provide handling on at least data, status, and error, for our demo purposes we'll handle just data. The argument object containing server: false is a rule that tells Nuxt to not fetch the data on the server, and to fetch it on the client instead. This prevents an error where Nuxt will try to hydrate the data on the client but it doesn't exist there, and the data will end up not being rendered on the client.

<template>
  <section>
    <div class="album" v-for="result in results" :key="result.nid">
      <p>{{ result.title[0].value }}</p>
      <p>{{ result.created[0].value }}</p>
      <p v-html="result.body[0].value"></p>
    </div>
  </section>
</template>

The API call should work but since nothing is displaying on the page, let's rectify that as well to show the data from the response, which is provided above (this code can be placed directly below the script tags in the file, right afterwards). v-for is Vue's way of conditionally showing data from an array and requires a "key" to identify each unique element - in this case, the node ID that Drupal uses behind the scenes is the perfect key to use. And because Drupal content nodes contain actual HTML in their body text, we have to use some Vue trickery with v-html to render that HTML in place of the <p> tag, otherwise this will just show the actual HTML code, which we don't want.

  1. With one exception, we can copy all of the code for this page to the other page (in my example /pages/phantogram.vue), only needing to modify the number in the API call to indicate the other taxonomy term ID.
<script setup lang="ts">
const { data: results } = await useFetch('http://localhost:80/api/content/2?_format=json', {
  server: false
});
</script>
  1. At this point our Nuxt app is functionally complete and retrieving data from our Drupal API! However, this app is far from being feature-complete for an actual user and there are some areas to note in which it can be further improved:

a. The Nuxt /pages directory isn't going to scale well once we start adding lots of different artists. It'd be highly impractical to have a Nuxt page for every single artist (and Drupal tag by extension). As a rhetorical exercise, while it could be vastly improved using music genres as tags instead, can you think of any other methods to simplify & reduce the number of required pages?

b. Related to the above point, our current two pages based around the Drupal taxonomy terms basically show exactly the same thing, and make almost the same exact API call (the only difference being a number at the end to denote a taxonomy term ID). Usually when you have this type of repetition, it means you only need one instance of that type. So realistically, we actually need only one page (or component) to show the results of this particular type of API call. This means that we probably need only one page (or component) to show any one particular artist. Can you think of how you might want to modify the app's architecture to support this? (Hint: making an API call in a parent component and sending the response data to another component, such as a child component, is the conventional way of doing this.)

c. The dates associated with the content nodes (that are displayed in the Nuxt app) are the dates of creation of the content, which naturally doesn't make sense to use. In our particular demo of music albums, it'd make more sense to get the release date of an album and use that instead. The text associated with each content node should also be something different. What would make more sense to use instead?

d. Part of the reason why I chose the example of music albums for this demo site was because it happens to be the concept of Spotify which is currently using Next.js with headless WordPress (how about that, eh?). Can you think of any features you might want to have here that Spotify already does, or maybe doesn't have?

e. The last thing to note that isn't a rhetorical question is that since Spotify has separate web and mobile apps, it happens to be a perfect demonstration of the benefit of a headless CMS, which is that it can provide content that can be readily consumed by different types of clients.

Conclusion

Hopefully this tutorial gave you something of a jumping-off point to start thinking about modifying the Nuxt app and adding on features, and adding more content to the Drupal CMS as well. Good luck!

0
Subscribe to my newsletter

Read articles from Steve Ham directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Steve Ham
Steve Ham