May 19, 2022

At Chapter Three, we believe that most Drupal websites could gain a performance boost by going headless. While this is how we’re planning to build future Drupal sites, we understand going all in on headless might not be feasible for existing sites.

This is why we built next-drupal for progressive adoption: decouple your Drupal site page by page, content type by content type, one at a time.

In this post, we’ll take a look at how we can decouple one content type from an existing Drupal site. We are going to take our own chapterthree.com site as an example.

Drupal's manage fields administrative interface

Here’s the plan:

  1. We currently have a Blog Post content type with the following fields: Title, Body and Author.
  2. We are going to decouple this content type from the main Drupal site.
  3. We’ll build new pages for every blog post using a headless front-end.

Install and configure Next.js module

We start by installing the Next.js module for Drupal. In a terminal run the following command to install the module with dependencies:

composer require drupal/next

Once composer completes, you can go ahead and enable the Next.js and Next.js JSON:API modules at /admin/modules.

Next, visit /admin/people/permissions and assign the following permission to the anonymous user: Issue subrequests.

Create a new next-drupal site

We can now create a new next-drupal site using the basic starter.

npx create-next-app -e <https://github.com/chapter-three/next-drupal-basic-starter-client>

Once the starter is created, copy the .env.example file to .env.local and update the following values:

NEXT_PUBLIC_DRUPAL_BASE_URL=http://localhost:8888
NEXT_IMAGE_DOMAIN=localhost

Where NEXT_PUBLIC_DRUPAL_BASE_URL is the path to your Drupal site.

Since we’re going to build everything from scratch, let’s delete the pages/index.tsx and pages/[...slug].tsx files.

Create a blog landing page

Let’s start by creating a landing page for our blog posts.

Fetch a list of blog posts

  1. Create a new file called index.tsx under pages/index.tsx

  2. Next.js requires two functions to fetch and display data:

    1. getStaticProps to fetch data
    2. a React page component to display data
  3. Let’s add a placeholder React component:

    export default function IndexPage() {
      return <p>Blog posts</p>
    }
    
  4. Add a function called getStaticProps with the following:

    import { drupal } from "lib/drupal"
    
    export async function getStaticProps(context) {
      const posts = await drupal.getResourceCollectionFromContext("node--blog_post", context)
    
      return {
        props: {
          posts,
        },
      }
    }
    

    getResourceCollectionFromContext is a helper function from next-drupal. Here we are telling next-drupal to fetch a collection of node-blog_post resources.

    If you add a console.log(posts) in your IndexPage component you should see it log the blog posts fetched from Drupal.

    import { drupal } from "lib/drupal"
    
    export default function IndexPage({ posts }) { // <--- get posts prop
      console.log(posts) // <--- log the posts content in console
      return <p>Blog posts</p>
    }
    
    export async function getStaticProps(context) {
      const posts = await drupal.getResourceCollectionFromContext("node--blog_post", context)
    
      return {
        props: {
          posts,
        },
      }
    }
    
  5. If you look at the content of posts, you will notice getResourceCollectionFromContext returns the node—blog_post will all the fields. Since we only care about some of the fields, let’s tell JSON:API to only return a subset of the fields.

    <aside> 💡 This ensures we are not fetching more data than we need.

    </aside>

    import { DrupalJsonApiParams } from "drupal-jsonapi-params"
    
    const posts = await drupal.getResourceCollectionFromContext("node--blog_post", context, {
    	// For the node--blog_post, only fetch the following fields.
    	params: new DrupalJsonApiParams()
          .addFields("node--blog_post", [
            "title",
            "body",
    				"created",
            "path",
            "uid"
          ])
          .getQueryObject(),
    })
    
  6. Next, let’s take a look at the uid field. This is the node author field. You will notice that JSON:API has included this field but not much about it. This is because by default JSON:API does not include related data. We can fix this by using an include:

    import { DrupalJsonApiParams } from "drupal-jsonapi-params"
    
    const posts = await drupal.getResourceCollectionFromContext("node--blog_post", context, {
      params: new DrupalJsonApiParams()
        .addFields("node--blog_post", [
          "title",
          "body",
    			"created",
          "path",
          "uid"
        ])
        .addInclude(["uid"])
        .getQueryObject(),
    })
    
  7. Now that we are including related data, let’s tell JSON:API what fields we want for those related resources:

    import { DrupalJsonApiParams } from "drupal-jsonapi-params"
    
    const posts = await drupal.getResourceCollectionFromContext("node--blog_post", context, {
      params: new DrupalJsonApiParams()
        .addFields("node--blog_post", [
          "title",
          "body",
    			"created",
          "path",
          "uid"
        ])
        .addFields("user--user", ["display_name"]) // <-- only fetch display_name for users
    		.addInclude(["uid"])
        .getQueryObject(),
    })
    
  8. We can add a sort field to sort the blog post by most recent.

    import { DrupalJsonApiParams } from "drupal-jsonapi-params"
    
    const posts = await drupal.getResourceCollectionFromContext("node--blog_post", context, {
      params: new DrupalJsonApiParams()
        .addFields("node--blog_post", [
          "title",
          "body",
    			"created",
          "path",
          "uid"
        ])
        .addFields("user--user", ["display_name"])
    		.addInclude(["uid"])
    		.addSort("created", "DESC") // <--- sort by date
        .getQueryObject(),
    })
    

Display a list of blog posts

Once we have our posts data, we can go ahead and render a list of blog posts in the React page component.

import { formatDate } from "lib/format-date"

export default function IndexPage({ posts }) {
  return posts?.length ? (
    posts.map((post) => (
      <article key={post.id}>
        <h2>{post.title}</h2>
        <p>
          Created by {post.uid.display_name} on {formatDate(post.created)}
        </p>
      </article>
    ))
  ) : (
    <p className="py-4">No blog posts found</p>
  )
}

The Basic starter template comes with Tailwind CSS out of the box. We can use utility classes to style our book collection.

export default function IndexPage({ posts }) {
  return (
    <div className="flex flex-col max-w-4xl mx-auto space-y-6 divide-y">
      {posts?.length ? (
        posts.map((post) => (
          <article key={post.id} className="pt-6 prose">
            <h2>{post.title}</h2>
            <p>
              Created by {post.uid.display_name} on {formatDate(post.created)}
            </p>
          </article>
        ))
      ) : (
        <p className="py-4">No blog posts found</p>
      )}
    </div>
  )
}

 

Progressive Decoupling walkthrough image 2

 

To add the default page layout with header and footer, you can wrap this page component with the Layout component.

import { DrupalJsonApiParams } from "drupal-jsonapi-params"

import { Layout } from "@/components/layout"
import { drupal } from "@/lib/drupal"
import { formatDate } from "@/lib/format-date"

export default function IndexPage({ posts }) {
  return (
    <Layout>
      <div className="flex flex-col max-w-4xl mx-auto space-y-6 divide-y">
        {posts?.length ? (
          posts.map((post) => (
            <article key={post.id} className="pt-6 prose">
              <h2>{post.title}</h2>
              <p>
                Created by {post.uid.display_name} on {formatDate(post.created)}
              </p>
            </article>
          ))
        ) : (
          <p className="py-4">No blog posts found</p>
        )}
      </div>
    </Layout>
  )
}

export async function getStaticProps(context) {
  const posts = await drupal.getResourceCollectionFromContext(
    "node--blog_post",
    context,
    {
      params: new DrupalJsonApiParams()
        .addFields("node--blog_post", [
          "title",
          "body",
          "created",
          "path",
          "uid",
        ])
        .addInclude(["uid"])
        .addFields("user--user", ["display_name"])
        .addSort("created", "DESC")
        .getQueryObject(),
    }
  )

  return {
    props: {
      posts,
    },
  }
}

That’s it. We now have a blog landing page built with blog post data from Drupal.

Progressive Decoupling walkthrough image 3

 

A page for every blog post

The next step is to create a page for every blog post. This page will display additional information about the post.

Create a new page at pages/[...slug].tsx. This is a special page. It’s called a dynamic page. It acts as an entry point for content or nodes that are created on Drupal. Example: when we visit http://localhost:3000/blog/a-blog-post, this is the page that is rendered.

Let’s tell next-drupal to build dynamic pages for node—blog_post resources.

Next.js requires three functions to fetch and display data for dynamic pages:

  • getStaticPaths to get a list of all available paths
  • getStaticProps to fetch data
  • A React page component to display data

Let’s add a placeholder React component:

export default function BlogPostPage() {
  return <p>Blog post</p>
}

Add a getStaticPaths function to fetch paths for existing posts.

import { drupal } from "lib/drupal"

export async function getStaticPaths(context) {
  return {
    paths: await drupal.getStaticPathsFromContext(["node--blog_post"], context),
    fallback: "blocking",
  }
}

Here we telling Next.js to build static pages for every blog post from Drupal. The fallback: blocking is how to handle new blog posts.

Next, add a getStaticProps function to fetch the current blog post from context.

import { drupal } from "lib/drupal"

export async function getStaticProps(context) {
	// Check if this path exist in Drupal.
  const path = await drupal.translatePathFromContext(context)

  if (!path) {
    return {
      notFound: true,
    }
  }

	// Fetch the current blog post from context.
  const post = await drupal.getResourceFromContext(path, context, {
    params: new DrupalJsonApiParams()
      .addFields("node--blog_post", ["title", "body", "created", "path", "uid"])
      .addInclude(["uid"])
      .addFields("user--user", ["display_name"])
      .getQueryObject(),
  })

  return {
    props: {
      post,
    },
  }
}

Update our React component to display the blog post.

import Link from "next/link"
import { DrupalJsonApiParams } from "drupal-jsonapi-params"

import { drupal } from "lib/drupal"
import { formatDate } from "lib/format-date"
import { Layout } from "components/layout"

export default function BlogPostPage({ post }) {
  return (
    <Layout>
      <div className="max-w-4xl mx-auto prose">
        <Link href="/" passHref>
          <a className="inline-flex mb-4 no-underline">← Back to Blog</a>
        </Link>
        <h1>{post.title}</h1>
        <p>
          Created by {post.uid.display_name} on {formatDate(post.created)}
        </p>
        {post.body && (
          <div dangerouslySetInnerHTML={{ __html: post.body.processed }} />
        )}
      </div>
    </Layout>
  )
}

export async function getStaticPaths(context) {
  return {
    paths: await drupal.getStaticPathsFromContext(["node--blog_post"], context),
    fallback: "blocking",
  }
}

export async function getStaticProps(context) {
  const path = await drupal.translatePathFromContext(context)

  if (!path) {
    return {
      notFound: true,
    }
  }

  const post = await drupal.getResourceFromContext(path, context, {
    params: new DrupalJsonApiParams()
      .addFields("node--blog_post", ["title", "body", "created", "path", "uid"])
      .addInclude(["uid"])
      .addFields("user--user", ["display_name"])
      .getQueryObject(),
  })

  return {
    props: {
      post,
    },
  }
}
Progressive Decoupling walkthrough image 4

Now that we have a page for every blog post, let’s link the posts from the front page to their individual pages.

import Link from "next/link"

export default function IndexPage({ posts }) {
  return (
    <Layout>
      <div className="flex flex-col max-w-4xl mx-auto space-y-6 divide-y">
        {posts?.length ? (
          posts.map((post) => (
            <article key={post.id} className="pt-6 prose">
              <h2>
                <Link href={post.path.alias} passHref>
                  <a className="no-underline">{post.title}</a>
                </Link>
              </h2>
              <p>
                Created by {post.uid.display_name} on {formatDate(post.created)}
              </p>
            </article>
          ))
        ) : (
          <p className="py-4">No blog posts found</p>
        )}
      </div>
    </Layout>
  )
}

That’s it. If you visit the front page and click on a post title, you should be taken to the post page.

What’s next?

You have now completely decoupled the blog post content type from the Drupal site. You can keep managing content in Drupal but the front-end is now handled in next-drupal. It’s faster, lighter and built using modern front-end tools.

You can deploy this new front-end to a hosting provider like Vercel or host it on your own node servers.

To run your Drupal site and the new decoupled site side by side, you can use a subdomain strategy i.e run your main site at example.com and the blog at blog.example.com or use a reverse proxy to run both sites under the same domain: example.com and example.com/blog.

To learn more about next-drupal and decoupled Drupal sites, visit our next-drupal page or get in touch with John to discuss decoupled Drupal for your next project.