Show recent posts in Docusaurus
TL;DR
At a high level, these are the steps.
- Create a custom plugin that extends the original blog plugin (plugin-content-blog).
- Disable the original blog plugin and use the new custom one.
- Import the JSON file containing the recent articles.
For details of each step, see Option 2.
Preamble
I'm in the camp of static site generators when it comes to creating a blog. Hands-down Docusaurus is the best that I've come across.
As soon as I got to understand Docusaurus, I ditched Hugo, my previous static site generator of choice. I knew that I will be using Docusaurus for the long term.
Despite my partiality towards Docusaurus, one function that I've found sorely missing is the ability to show the most recent blog posts in the home page. And I'm not the only one.
Ever since I moved my blog over in February 2023, I've been looking for a method to do this on and off over the past year.
Until recently, the best explanation that I've found describes in abstract terms how to do it but doesn't show any concrete steps.
Option 1
After a few months, I decided that there was no easy way. So I thought of a method where I render the posts client side using the RSS feed from the blog.
It's really more of a workaround rather than an "actual" solution.
This is achieved by using useEffect
that fetches and parses the RSS feed. Here is how I did it.
Import the useEffect function if not already done.
import useEffect from 'react';
Fetch the RSS feed in useEffect
:
const [feed, setFeed] = useState<Record<string, any>[] | null>(null);
useEffect(function fetchFeed() {
fetch(`${WEBSITE}/blog/rss.xml`)
.then((resp) => resp.text())
.then((xmlText) => {
const xml = new window.DOMParser().parseFromString(xmlText, 'text/xml');
const items = xml.querySelectorAll('item');
const latestArticle = 0;
const endingArticle = 2;
const posts = extractPosts(items, latestArticle, endingArticle);
setFeed(posts);
});
}, []);
The fetch
function retrieves the RSS feed from the published website (indicated by the variable WEBSITE
). The feed is then converted into XML (line 7). The articles are contained in item
elements (line 9). In this code segment, the latest article is selected (latestArticle = 0
) along with the second latest one (endingArticle = 2
), meaning that the newest 2 articles will be shown.
The posts are extracted using extractPosts
.
Extract the posts
function extractPosts(elements: NodeListOf<Element>, start = 0, count = 1) {
let subset = [];
for (let i = start; i < start + count; i++) {
if (i < elements.length) {
subset.push({
title: elements[i].querySelector('title').textContent,
link: elements[i].querySelector('link').innerHTML,
pubDate: elements[i].querySelector('pubDate').textContent,
description: elements[i].querySelector('description').textContent ?? '',
});
}
}
return subset;
}
This will make the recent articles are available in the index page (setFeed(posts)
), so they can then be rendered to your desire.
Drawbacks
While this method is straightforward, there are some drawbacks.
- First, it depends on the RSS feed from the production website, which means that it will not reflect the articles that you just added to the site in development.
- Second, since the list is rendered client-side, it will (generally) not be picked up by search engines. Granted, this may not impact the SEO performance much since it's just a short list of articles.
- Third, since the code is fetching another resource after the page is loaded, there will be a delay before the list is rendered. Depending on your preference and how you layout your page, this might be a dealbreaker.
- Fourth, depending on how the server hosting the site behaves, the feed may be stale i.e. the feed may not show the latest articles from the site, making the list show inaccurate data.
Despite all these shortcomings, this was the only solution I had for a while.
But the problem about showing stale data (problem #4) was painful enough for me to keep looking for a better solution.
Option 2
After spending the better part of Easter Friday, I finally got something working.
I found an article that actually describes step-by-step how the recent articles can be placed in the index page. However, this method requires replacing the index page with the blog listing page. This was a bit more drastic than what I was expecting - I didn't want to have to redesign the blog listing page to look like the index page.
Instead I opted to use the method suggested by Sébastien Lorber (one of Docusaurus' maintainers) in this Github issue which is to create an intermediate JSON file. Then the index page will import this JSON file and render the posts on the index page.
As described in the TL;DR, the steps are:
Create a custom plugin that extends the original blog plugin (plugin-content-blog)
const fs = require('node:fs');
const blogPluginExports = require('@docusaurus/plugin-content-blog');
const defaultBlogPlugin = blogPluginExports.default;
async function blogPluginEnhanced(...pluginArgs) {
const blogPluginInstance = await defaultBlogPlugin(...pluginArgs);
const dir = '.docusaurus';
return {
...blogPluginInstance,
contentLoaded: async function (data) {
let recentPosts = [...data.content.blogPosts]
// Only show published posts.
.filter((p) => !p.metadata.unlisted)
.slice(0, 3);
recentPosts = recentPosts.map((p) => {
return {
id: p.id,
metadata: p.metadata,
};
});
fs.mkdirSync(dir, {
recursive: true, // Avoid error if directory already exists.
});
const fd = fs.openSync(`${dir}/recent-posts.json`, 'w');
fs.writeSync(fd, JSON.stringify(recentPosts));
return blogPluginInstance.contentLoaded(data);
},
};
}
module.exports = {
...blogPluginExports,
default: blogPluginEnhanced,
};
Create a plugins directory in root of the project (same level as src). Place the file recent-blog-posts.js into this directory as the plugin. (You can choose different names for the directory and file - they are not treated any differently.)
By including the plugin (line 3), the plugin is being extended.
Line 17 is of note. It checks to make sure that only listed posts will be included (filter((p) => !p.metadata.unlisted)
). Remember that this value only takes effect in production.
On line 18, we tell the plugin to return the latest 3 posts.
Lines 20-25 are optional. They are there to keep the intermediate JSON file small by omitting the full contents of the posts.
Lines 30-31 write the posts into the intermediate JSON which is named recent-posts.json
and placed in the .docusaurus directory.
Disable the original blog plugin and use the new custom one
This step requires modifying the configuration file docusaurus.config.js. The exact changes depend on whether the blog plugin was added manually (standalone) or as part of the theme.
If the plugin is configured standalone, then it would be in the plugins
array:
const config = {
plugins: [
[
'@docusaurus/plugin-content-blog',
{
// rest of blog plugin options
},
],
],
// rest of config
};
To use the new custom plugin, swap out the original one with the new one.
const config = {
plugins: [
[
'./plugins/recent-blog-posts',
{
// rest of blog plugin options
},
],
],
// rest of config
};
If the plugin is included as part of a preset (e.g. the classic preset @docusaurus/preset-classic
), like the majority of users who follow the installation guide do, then the configuration is placed in the presets
array:
const config = {
presets: [
[
'classic',
/** @type {import('@docusaurus/preset-classic').Options} */
({
blog: {
showReadingTime: true,
// rest of blog plugin options
},
// rest of preset options
}),
],
],
// rest of config
};
To use the new custom plugin, disable the blog plugin in the preset:
const config = {
presets: [
[
'classic',
/** @type {import('@docusaurus/preset-classic').Options} */
({
blog: false,
// rest of preset options
}),
],
],
// rest of config
};
And add the custom plugin into the plugins
array (you may have to create the entire plugins
property if it is not already present):
const config = {
plugins: [
[
'./plugins/recent-blog-posts',
{
showReadingTime: true,
// rest of blog plugin options
},
],
],
// rest of config
};
Import the JSON file containing the recent articles
Add the following import statement into your index page (or component) and you can access the recent articles.
import recentPosts from '@site/.docusaurus/recent-posts.json';
TypeScript users, you may see an wriggly underline error in your IDE saying Cannot find module '@site/.docusaurus/recent-posts.json'. Consider using '--resolveJsonModule' to import module with '.json' extension.
To resolve this issue, follow the instructions and add "resolveJsonModule": true
to the tsconfig.json file found at the root.
Conclusion
I would have preferred to be able to specify the number of recent posts to display by specifying the number as an option in docusaurus.config.js but I couldn't get it to work.
Adding an additional option for the plugin like this:
const config = {
plugins: [
[
'./plugins/recent-blog-posts',
{
showReadingTime: true,
recentPosts: 5,
// rest of blog plugin options
},
],
],
// rest of config
};
raises this error:
[ERROR] ValidationError: "recentPosts" is not allowed
This is due to the validation of options by the original plugin. I do not think there is much value in modifying the original plugin (or to fork it) to achieve this minor enhancement.
I'm pretty happy with this outcome as it is. Nevertheless, if you have an idea of how to make the number of recent posts configurable in docusaurus.config.js, hit me up on Twitter.