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.
Here’s the plan:
- We currently have a Blog Post content type with the following fields: Title, Body and Author.
- We are going to decouple this content type from the main Drupal site.
- 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
-
Create a new file called
index.tsx
underpages/index.tsx
-
Next.js requires two functions to fetch and display data:
getStaticProps
to fetch data- a React page component to display data
-
Let’s add a placeholder React component:
export default function IndexPage() { return <p>Blog posts</p> }
-
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 ofnode-blog_post
resources.If you add a
console.log(posts)
in yourIndexPage
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, }, } }
-
If you look at the content of posts, you will notice
getResourceCollectionFromContext
returns thenode—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(), })
-
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 aninclude
: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(), })
-
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(), })
-
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>
)
}
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.
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 pathsgetStaticProps
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,
},
}
}
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.