Table of contents
There are many Headless CMSs out there and many other tools that let us make a blog easily and quickly. Today we look at building a blog with Directus and Next.js. We will use MDX to store our blog content in Directus. We are also going to use Next.js's on-demand incremental static regeneration feature which lets us add or update content on our website without triggering a re-build.
Demo -
Note that incremental static regeneration will be referred to as ISR from now on
Why On-Demand ISR over regular ISR?
Next.js supports ISR for a few years now but on-demand ISR makes it better. Before, we would specify a revalidate
property with the number of seconds to cache the data. This had a few disadvantages -
- The cache would be invalidated regularly even if the data in the server had not changed. This would also result in unnecessary API calls.
- Often, the content served would be stale.
On-demand ISR solves both the aforementioned problems with regular ISR. This is done by triggering a cache revalidation via webhooks. So whenever the content is updated on the server (or CMS), a webhook event can be fired which will create or update the required static pages
Setting up a Directus project
Although Directus can be self-hosted, it has a cloud offering with a decent free tier. Go ahead and sign up for an account if you haven't already. Click "Create Project" in the dashboard (you might be prompted to create one during the onboarding process) and give your project a name. Under "Infrastructure", select "Community Cloud" and under "Starting Template", select "Empty Project". Now click "Create Project" and within 2 minutes, it should be created.
After the project has been created, click on it on the dashboard and then click "Open Project". Here, on the login screen, enter the credentials that have been sent to the email address associated with your Directus account.
Setting up our Blog collection
On, Directus, it should say that there are no collections. Let us create a collection called "Blog". Click on the arrow in the top-right corner and check "Status", "Created on" and "Updated On". We will add more fields later on. Now click on the checkmark and we should be able to see our collection's schema -
Let us add a few more fields. Create one called with the type "Input" and give it the key "title". Make it required and hit save.
Add one more field with the type "Markdown" and give it the key "content". Make this required too and hit save.
We need to also add a slug for our blog posts. Create a new field with the "Input" type with key "slug" and make it required. Now click "Continue in Advanced Field Creation Mode". Under "Interface" check the "Slugify" option and click the checkmark to create the field.
Adding some sample data
Adding content in Directus is extremely easy! Go to the content tab in the sidebar (the first one with a 3d box icon) and select the Blog collection. Now click "Create Item". Here add a title and some content and a slug. Also, make sure to change the status to "Published". We will later use this status to manage API access. Note that you can use markdown here. After you are done adding the content, click the checkmark to save it.
Setting up permissions for our API
Go to the settings tab on the sidebar and then click on "Roles & Permissions" in the settings navigation. Next, click on "Public" -
Here we can see our Blog collection. Click on the red not allowed sign under the eye (this denotes view permission). In the dropdown, select "Use Custom".
Unser "Item Permissions", add the following rule allowing public view access to only published items -
Under "Field Permissions", select all and click on the checkmark to save the permissions.
Creating a blog with Next.js and Directus
Let us start by creating a new Next.js app -
npx create-next-app nextjs-directus-on-demand-isr
# OR
yarn create next-app nextjs-directus-on-demand-isr
After it has been created, open the project in your favorite code editor.
Next, install the Directus JS SDK -
npm install @directus/sdk
# OR
yarn add @directus/sdk
Now, make a file called lib/directus.js
and add the following code -
import { Directus } from "@directus/sdk";
const directus = new Directus(process.env.DIRECTUS_URL);
export default directus;
Here, we are initializing the SDK with the URL to our Directus backend. We must set this URL as an environment variable. Go ahead and create a new file called .env.local
and add the environment variable -
DIRECTUS_URL=<PATH_TO_YOUR_DIRECTUS_INSTANCE_WITHOUT_ANY_OF_THE_PATHS>
Make sure to appropriately replace the URL with the one for your instance.
Now, open the pages/index.js
file and replace it with the following code -
import Head from "next/head";
import Link from "next/link";
import directus from "../lib/directus";
import styles from "../styles/Home.module.css";
export default function Home({ posts }) {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to A demo of Next.js with Directus
</h1>
<div className={styles.grid}>
{posts.map((post) => (
<Link key={post.id} href={post.slug} passHref>
<a className={styles.card}>
<h2>{post.title}</h2>
</a>
</Link>
))}
</div>
</main>
</div>
);
}
export const getStaticProps = async () => {
const res = await directus.items("blog").readByQuery({
limit: -1,
fields: ["title", "slug", "id"],
});
return {
props: {
posts: res.data,
},
};
};
Here, we are fetching all the blog posts but only the title, slug, and id fields. We do this in getStaticProps
and pass the data as a prop to the client. Hence, the fetching is done on the server environment, only at build time. (note that in a development environment, getStaticProps
runs on every request)
Later on in the article, we are going to be looking at on-demand ISR that will run getStaticProps
when a webhook is triggered.
Once you run yarn dev
and open up https://localhost:3000 on your web browser, you should be able to see your blog posts -
Now, if we click on the blog post cards, it will lead us to a 404 as we haven't set up our blog post pages yet. Let us do that now.
Create a new file pages/[slug].js
and add the following code -
import directus from "../lib/directus";
import styles from "../styles/BlogPost.module.css";
const BlogPage = ({ post }) => {
return (
<div className={styles.container}>
<h1>{post.title}</h1>
<p>{post.content}</p>
</div>
);
};
export const getStaticProps = async ({ params }) => {
const res = await directus.items("blog").readByQuery({
filter: { slug: params.slug },
fields: ["title", "content"],
});
return {
props: {
post: res.data[0],
},
};
};
export const getStaticPaths = async () => {
const res = await directus.items("blog").readByQuery({
limit: -1,
fields: ["slug"],
});
return {
paths: res.data.map((post) => ({
params: {
slug: post.slug,
},
})),
fallback: false,
};
};
export default BlogPage;
Here, in getStaticPaths
, we are fetching only the slug for all blog posts. Then we are mapping over it and create an array of params to let Next.js know the paths that exist. Lastly, we set fallback
to false
so that any path not in the paths array redirects to a 404.
In getStaticProps
we do a query where we filter by the slug
field. We also specifically ask for only the title
and content
fields.
Lastly, we pass the data in through props and render it on the client-side.
Also, we can add some CSS for this page. Create a new file called styles/BlogPost.module.css
and add the following to it -
.container {
padding: 0 2rem;
}
.main {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
Now if we see our blog post page, we see that the content is rendering as a string and is not getting parsed as markdown -
Let's fix this
Rendering MDX for content
Although MDX supports more advanced features like the ability to render React components, we are not going to be looking at that in this article. However, let us focus on parsing the markdown and rendering the necessary HTML.
We are going to be using next-mdx-remote
for this tutorial. Let us install it -
npm install next-mdx-remote
# OR
yarn add next-mdx-remote
Now, replace the code in pages/[slug].js
with the following -
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote";
import directus from "../lib/directus";
import styles from "../styles/BlogPost.module.css";
const BlogPage = ({ post }) => {
return (
<div className={styles.container}>
<h1>{post.title}</h1>
<MDXRemote {...post.content} />
</div>
);
};
export const getStaticProps = async ({ params }) => {
const res = await directus.items("blog").readByQuery({
filter: { slug: params.slug },
fields: ["title", "content"],
});
return {
props: {
post: {
title: res.data[0].title,
content: await serialize(res.data[0].content),
},
},
};
};
export const getStaticPaths = async () => {
const res = await directus.items("blog").readByQuery({
limit: -1,
fields: ["slug"],
});
return {
paths: res.data.map((post) => ({
params: {
slug: post.slug,
},
})),
fallback: false,
};
};
export default BlogPage;
Now, if we navigate to a blog post, we can see the rendered markdown -
Using Next.js On-demand ISR with Directus
If we build the Next.js application and serve a production build and then make changes in Directus, they will not reflect in our website unless we re-build it. Here we got 3 options -
- Trigger a new build every time content is added/updated/deleted
- Use regular ISR with a specified time for which the cache should be retained
- Use on-demand ISR to only re-build that specific page only when it is needed
With the first option, it can be expensive and time-consuming. Re-building also invalidates the cache for all pages in many cases. This might cause issues when you are dealing with larger websites.
With the second option, there will be unnecessary API calls to our server and data might be stale.
This leaves us with the third option. Although setting it up is slightly more complicated than the other 2 methods, it does not have any of the downsides the other 2 methods have. And, I am going to show you how exactly to set it up so it shouldn't be very hard :)
Note: Currently the website is hosted locally but Directus needs a publicly accessible URL as a webhook. You can either host it somewhere and then test it out or start a local tunnel using something like Ngrok
Create a new file under pages/api/revalidate.js
and add the following code -
import directus from "../../lib/directus";
const handler = async (req, res) => {
const { collection } = req.body;
const headers = req.headers;
if (!headers["x-webhook-secret"]) {
return res.status(403).send("Forbidden");
}
const receivedSecret = headers["x-webhook-secret"];
const secret = process.env.REVALIDATE_SECRET;
if (receivedSecret !== secret) {
return res.status(403).send("Forbidden");
}
if (collection === "blog") {
const { keys } = req.body;
for (const key of keys) {
const directusRes = await directus
.items(collection)
.readOne(key, { fields: ["slug"] });
await res.unstable_revalidate(`/${directusRes.slug}`);
await res.unstable_revalidate("/");
}
}
return res.status(200).send("Success");
};
export default handler;
This is a simple Next.js API route.
First, we check for the x-webhook-secret
header and compare it to a preset webhook secret, set as an environment variable. We should always use a webhook secret to prevent spam. It can also be a security risk in some cases (but here it is not as we are not relying on data sent as an event payload for input).
Directus sends us some event payload and we are destructuring the collection field which contains the name of the collection where the change was made. We check if this is the blog collection and then go ahead with revalidating the pages. Although this does not make much sense here, if our pages had multiple sets of pages that could be revalidated, we could specifically just revalidate a set of pages instead of all sets of pages. (for example, if there was a landing and then a blog page, for changes to the landing collection, we could just revalidate the landing page but for changes to the blog page, we could revalidate only the specific blog page and the blog index).
Now, the event payload does not contain the slug for the blog post but it has the id. We use the Directus SDK to get the slug corresponding to that id and then revalidate that slug page and the home page (as it has the blog index).
Lastly, open up the .env.local
file and add the REVALIDATE_SECRET
environment variable. For the value, it can be a random string. The easiest way would be to use the output of the following command -
openssl rand -base64 32
To test this out, we cannot use the development environment as getStaticProps
runs every time a request is made on the development environment. Either build the site with npm run build
or yarn build
, serve it locally with npm run start
or yarn start
, and then use a local tunneling solution like Ngrok or else, deploy it to a hosting platform like Vercel.
Now head over to your Directus instance and go to the settings tab. Now click on "Webhooks" in the side navigation and create a new one. Give it a name and for the URL field, add your Ngrok URL or your hosted instance. Make sure that the slug is /api/revalidate
. The URL should look like https://<my-domain>/api/revalidate
. Make sure the status is set to active and the "Send Event Data" checkbox is checked.
Now, add a header called x-webhook-secret
with the value of the secret you created earlier and set it as an environment variable. Under "Triggers", check all the actions and the blog collection. Now click the checkmark to save it. Here is what it looks like for me -
Conclusion
That has been quite a lot! Let us go over the things we did -
- First, we create a project in Directus and created a schema for our blog posts
- We created a Next.js application and added the Directus SDK
- We displayed our blog posts on the home page and the post with content on its own page
- We used
next-mdx-remote
to render markdown - We used Next.js on-demand ISR to revalidate the cache whenever required
Hope you liked this tutorial! Do share it if you have found it useful and you can follow me on Twitter too :)
See you on my next blog 🤞