<!--
  canonical-url: https://www.avonture.be/blog/docusaurus-relatedposts
  published:     2025-09-03
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Displaying related posts below our Docusaurus article

> Enhance your Docusaurus blog by showing related posts beneath each article for better content discovery.

---
![Displaying related posts below our Docusaurus article](/img/v2/docusaurus_react.webp)

**TL;DR**

This guide explains how to add a 'Related Posts' section to your Docusaurus blog posts. You'll learn how to create a custom `<RelatedPosts>` React component that displays a list of articles sharing the same primary tag (`mainTag`). The process involves building a reusable `<Card>` component, creating a utility script to fetch and parse blog post data, and finally, overriding the Docusaurus `BlogPostItem` theme to inject your new component at the bottom of every article.

Docusaurus can display a list of tags, but not related articles. *That `mainTag` is only useful if your tags are consistent; [Tags management in Docusaurus](/blog/docusaurus-tags) is the cleanup script I run to keep them so.*

When you browse my blog, you will see a list of **Related Blog Posts** below every article. This is a component I coded using AI. *For articles that follow a deliberate reading order rather than a shared tag, see [Organize Your Docusaurus Content with a Custom Series Component](/blog/docusaurus-series).*

The objective of our component is to display something like this:

![Related Blog posts](./images/related.webp)

Here is the final result once wired up: up to six cards, picked from posts sharing the same `mainTag`.

![The final result](./images/final.webp)

## Why It Works

* Matching runs on `mainTag`, a single custom front matter field — no manual curation, no keyword weighting, just "same primary topic."
* The `<RelatedPosts>` component reuses the same `<Card>` you'd build for any post listing, so there's only one card layout to maintain across the site.
* It's a theme override, not a fork: `BlogPostItem` still renders everything Docusaurus normally does, we just inject one extra component at the bottom.

## We need a Card component

Please follow the instructions in the [Using the Reusable Card component of Docux](/blog/docusaurus-cards#using-the-reusable-card-component-of-docux) chapter.

You will have to create up to five files in a `src/components/Card/` folder (and its subfolders).

Return to this article after you have created the five files.

## We need a way to extract information from blog posts

If you haven't already, please create the `src/components/Blog/utils/posts.ts` file. It will contain a helper function that we can reuse for several components.

That helper will scan each Markdown file with an `.md` or `.mdx` extension in the `blog` subfolder.

For each file, the script will look at the YAML front matter and use some of its properties. For example:

* If the blog post has `draft: true` or `unlisted: true`, the post will be ignored.
* If the blog post has a `slug`, it will be used. If not, the slug will be generated by the code.
* If the blog post has an associated image, it will be used. If not, a default image will be used.
* Then, the helper will return the list of posts and their properties.

Some properties, like `mainTag`, are custom.

So, just copy and paste the content of the file below and create the `src/components/Blog/utils/posts.ts` file in your project's structure.

**src/components/Blog/utils/posts.ts**

````typescript
/**
 * getBlogMetadata
 *
 * Extracts metadata from all MDX blog posts located in the `/blog` directory.
 * Uses Webpack's `require.context` to dynamically load and parse frontmatter
 * from each post, returning a structured array of metadata objects.
 *
 * Behavior:
 * - Resolves permalinks based on `slug` or folder structure
 * - Normalizes image paths for static assets
 * - Filters out invalid or missing entries
 *
 * Returned metadata includes:
 * - `title`: Post title
 * - `description`: Short summary
 * - `image`: Resolved image path
 * - `draft`: Boolean flag for unpublished posts
 * - `unlisted`: Boolean flag for hidden posts
 * - `permalink`: URL path to the post
 * - `tags`: Array of tags
 * - `mainTag`: Primary tag (optional); used by the RelatedBlogPost component
 * - `authors`: Array of author names
 * - `date`: Publication date
 * - `series`: Series name (optional); used by the SeriesBlogPost component
 *
 * Usage:
 * ```ts
 * import { getBlogMetadata } from './posts';
 * const posts = getBlogMetadata();
 * const withDrafts = getBlogMetadata({ includeDrafts: true });
 * ```
 *
 * By default, posts flagged `draft` or `unlisted` are excluded so consumers don't have to
 * remember to filter them out themselves. Pass `includeDrafts`/`includeUnlisted` to opt back in
 * (e.g. author-facing counters that need to show "in progress" articles).
 *
 * Note:
 * This function is intended for use in static site generation or client-side rendering
 * where Webpack's `require.context` is available.
 */

/**
 * A tag as it reaches these helpers: a plain slug string from front matter, or —
 * defensively, since related.ts and TagArticlesPage both handle it — a
 * Docusaurus-normalised tag object.
 */
export type BlogTag = string | { label: string };

export interface BlogPostMetadata {
  authors: string[];
  blueskyRecordKey: string | null;
  /**
   * ISO date string from front matter, e.g. "2026-08-28". Every article under blog/ carries
   * one (blog authoring convention, and the blog plugin itself refuses a post without a
   * date), so this is not optional.
   *
   * Runtime note: plugins/frontmatter-loader actually hands back a real `Date` instance for
   * a typical unquoted YAML date (see that plugin's own header comment) — not a string.
   * Declaring `string` here anyway matches the pre-existing contract every niveau 1-4 `.tsx`
   * consumer already compiles against (`Post.date?: string`, …); every one of them passes
   * this straight into `new Date(...)`, which accepts a `Date` argument just as well as a
   * string, so nothing behavioral changes. Widening this to the more literally accurate type
   * would ripple into every already-migrated consumer — out of scope here.
   */
  date: string;
  description: string | undefined;
  draft: boolean;
  image: string | undefined;
  mainTag: string | null;
  permalink: string;
  series: string | null;
  tags: BlogTag[];
  title: string;
  unlisted: boolean;
}

interface RawFrontMatter {
  title?: string;
  slug?: string;
  image?: string;
  authors?: string[];
  blueskyRecordKey?: string;
  date?: BlogPostMetadata["date"];
  description?: string;
  draft?: boolean;
  mainTag?: string;
  series?: string;
  tags?: BlogTag[];
  unlisted?: boolean;
}

// The `!!…!` prefix is load-bearing, do not simplify it away: it bypasses the
// rules configured by @docusaurus/plugin-content-blog and reads the files with
// plugins/frontmatter-loader instead. Without it, Webpack MDX-compiles every
// file under `blog/`, and any article flagged `draft: true` crashes the
// production build with "Blog post not found for filePath=…" — because the blog
// plugin drops drafts from its post list while this context still pulls them in.
// See plugins/frontmatter-loader/index.cjs for the full story.
import { useTranslationState, slugFromPermalink } from "./translations";

const posts = require.context(
  "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../blog",
  true,
  /\.mdx?$/,
);

// Second corpus: the French translations. Needed because the context above reads `blog/` — the
// English source — whatever locale is being rendered, so every card, list and related-post
// block showed an English title on a French page even for articles that ARE translated.
//
// The locale is spelled out rather than computed: `require.context` is a webpack construct and
// its path must be statically analysable. Adding a locale means adding a context here.
const translatedPosts = require.context(
  "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../i18n/fr/docusaurus-plugin-content-blog",
  true,
  /\.mdx?$/,
);

/** slug -> the translated `title`/`description`, for the one non-default locale we ship. */
function buildTranslatedFrontMatter(): Map<string, { title: string; description: string }> {
  const bySlug = new Map<string, { title: string; description: string }>();

  for (const key of translatedPosts.keys()) {
    const post = translatedPosts(key) as { frontMatter: RawFrontMatter };
    if (!post.frontMatter?.title) continue;

    const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, "");
    const slug = post.frontMatter.slug
      ? post.frontMatter.slug.replace(/^\/+|\/+$/g, "").replace(/^blog\//, "")
      : dir.split("/").pop() ?? "";

    if (slug) {
      bySlug.set(slug, {
        title: post.frontMatter.title,
        description: post.frontMatter.description ?? "",
      });
    }
  }

  return bySlug;
}

const TRANSLATED_FRONT_MATTER = buildTranslatedFrontMatter();

export function getBlogMetadata({
  includeDrafts = false,
  includeUnlisted = false,
}: { includeDrafts?: boolean; includeUnlisted?: boolean } = {}): BlogPostMetadata[] {
  return posts
    .keys()
    .map((key): BlogPostMetadata | null => {
      const post = posts(key) as { frontMatter: RawFrontMatter };

      // A Markdown file under `blog/` without a title is not an article
      // (a fragment, a note, a readme…); the blog plugin ignores it, so do we.
      if (!post.frontMatter?.title) {
        return null;
      }

      const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, "");

      let permalink: string;
      if (post.frontMatter.slug) {
        permalink = post.frontMatter.slug.startsWith("/")
          ? post.frontMatter.slug
          : `/blog/${post.frontMatter.slug.replace(/^\//, "")}`;
      } else {
        permalink = `/blog/${dir}/`;
      }

      let imageUrl = post.frontMatter.image;
      if (imageUrl && imageUrl.startsWith("./")) {
        imageUrl = `/blog/${dir}/${imageUrl.replace("./", "")}`;
      }

      return {
        authors: post.frontMatter.authors || [],
        blueskyRecordKey: post.frontMatter.blueskyRecordKey || null,
        date: post.frontMatter.date as BlogPostMetadata["date"],
        description: post.frontMatter.description,
        draft: post.frontMatter.draft || false,
        image: imageUrl,
        mainTag: post.frontMatter.mainTag || null,
        permalink,
        series: post.frontMatter.series || null,
        tags: post.frontMatter.tags || [],
        title: post.frontMatter.title,
        unlisted: post.frontMatter.unlisted || false,
      };
    })
    .filter((post): post is BlogPostMetadata => post !== null)
    .filter(
      (post) => (includeDrafts || !post.draft) && (includeUnlisted || !post.unlisted),
    );
}

/**
 * Locale-aware counterpart of `getBlogMetadata()` — the one every rendering surface should use.
 *
 * `getBlogMetadata()` reads `blog/` through `require.context`, so it always returns the **English**
 * corpus, whatever locale the page is being rendered in. Left unfiltered, a French listing shows
 * entries whose titles are English and whose pages open on English prose — which reads as a broken
 * site rather than as a partially translated one.
 *
 * On the default locale this is `getBlogMetadata()` unchanged, so switching a call site over is
 * always safe.
 *
 * Note this filters, it does not translate: the titles and descriptions of the surviving entries
 * still come from the English source. Showing the translated ones is a separate (larger) job —
 * see TODO 0119, lot C, "rendre le corpus de posts.ts conscient de la locale".
 */
export function useBlogMetadata(
  options: { includeDrafts?: boolean; includeUnlisted?: boolean } = {},
): BlogPostMetadata[] {
  const { filterTranslated, isDefaultLocale } = useTranslationState();

  const visible = filterTranslated(getBlogMetadata(options), (post) =>
    slugFromPermalink(post.permalink),
  );

  if (isDefaultLocale) return visible;

  // Overlay the translated title and description. Only these two are localized: `permalink`,
  // `image`, `tags`, `series` and `date` are shared with the English source by design — the
  // translation copies them byte for byte (see the translation contract in TODO 0119).
  return visible.map((post) => {
    const translated = TRANSLATED_FRONT_MATTER.get(slugFromPermalink(post.permalink));
    if (!translated) return post;

    return {
      ...post,
      title: translated.title,
      description: translated.description || post.description,
    };
  });
}
````

## Our RelatedPosts component

Now, please create the file `src/components/Blog/RelatedPosts/index.tsx`:

**src/components/Blog/RelatedPosts/index.tsx**

```typescript
/**
 * 🔗 RelatedPosts Component
 *
 * Displays a list of related blog posts based on shared tags or a designated `mainTag`.
 * Designed for Docusaurus blogs to enhance content discoverability.
 *
 * Behavior:
 * - Uses `useBlogPost()` to get current post metadata
 * - Delegates the selection to `useRelatedPosts()` (mainTag, then fallback on shared tags,
 *   current post excluded, newest first) so this block and `MobileQuickLinks` stay in sync
 *
 * Styling:
 * - Uses Docusaurus grid and card classes
 * - Inline styles for layout and visual polish
 *
 * Returns:
 * - A responsive grid of related blog post cards, or a fallback message if none found
 */

import type { JSX } from "react";
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
import Translate from "@docusaurus/Translate";
import { useRelatedPosts } from "@site/src/components/Blog/utils/related";
import PostCard from "@site/src/components/Blog/PostCard";
import styles from "./styles.module.css";

interface Props {
  /** Number of related posts to display */
  count?: number;
  /** Whether to show post descriptions */
  description?: boolean;
}

export default function RelatedPosts({
  count = 3,
  description = false,
}: Props): JSX.Element | null {
  const { metadata } = useBlogPost();
  // `mainTag` is a project-specific front matter field, so Docusaurus types it
  // as `unknown` — narrow it to what useRelatedPosts() expects.
  const mainTag = metadata.frontMatter.mainTag as string | undefined;
  const tags = metadata.frontMatter.tags || [];

  // Hook before early return: useRelatedPosts reads the locale-aware corpus, and it already
  // returns [] when there is no tag to match on.
  const related = useRelatedPosts({
    mainTag,
    tags,
    excludePermalink: metadata.permalink,
    count,
  });

  if (!mainTag && tags.length === 0) {
    return null;
  }

  if (!related.length) {
    return (
      <p>
        <Translate id="blog.relatedPosts.noRelated">No related posts.</Translate>
      </p>
    );
  }

  return (
    <div className={styles.relatedPosts}>
      <h3>
        <Translate id="blog.relatedPosts.title">Related posts</Translate>
      </h3>
      <div className="row">
        {related.map((post) => (
          <PostCard
            key={post.permalink}
            layout="small"
            post={description ? post : { ...post, description: null }}
          />
        ))}
      </div>
    </div>
  );
}
```

## Overriding the BlogPostItem template

We now need to override Docusaurus's **BlogPostItem** template so we can inject our `<RelatedPosts>` component at the bottom of each blog post.

In a terminal, run `yarn docusaurus swizzle @docusaurus/theme-classic BlogPostItem`, then select `Javascript`, then `Eject`, and finally `YES`.

Many files will be created in your Docusaurus project's `src/theme/BlogPostItem` folder. We can safely remove all files except for the `src/theme/BlogPostItem/index.js` file, which we need to edit.

So, please remove all files and folders under `src/theme/BlogPostItem` except for the `index.js` file.

In the code below, the highlighted lines are the ones we need to add.

**src/theme/BlogPostItem/index.js**

```javascript
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
import BlogPostItemContainer from "@theme/BlogPostItem/Container";
import BlogPostItemContent from "@theme/BlogPostItem/Content";
import BlogPostItemFooter from "@theme/BlogPostItem/Footer";
import BlogPostItemHeader from "@theme/BlogPostItem/Header";
import clsx from "clsx";

import RelatedPosts from "@site/src/components/Blog/RelatedPosts";

// apply a bottom margin in list view
function useContainerClassName() {
  const { isBlogPostPage } = useBlogPost();
  return !isBlogPostPage ? "margin-bottom--xl" : undefined;
}
export default function BlogPostItem({ children, className }) {
  // We need to retrieve the isBlogPostPage flag
  const { metadata, isBlogPostPage } = useBlogPost();
  const containerClassName = useContainerClassName();
  return (
    <BlogPostItemContainer className={clsx(containerClassName, className)}>
      <BlogPostItemHeader />
      <BlogPostItemContent>{children}</BlogPostItemContent>
      <BlogPostItemFooter />
      // highlight-next-line
      {isBlogPostPage && <RelatedPosts count="6" description="false" />}
    </BlogPostItemContainer>
  );
}
```

Now, because we have just introduced an override, we need to restart our Docusaurus server so, the changes can take effect.

> **Run npm run start:**
>
> If you're running Docusaurus locally, just run `npm run start` in your console.
> If, like me, you are running Docusaurus with Docker, just stop the container and start a new one.

## Editing our blog posts

We have made all the required configurations, but when you visit your blog, you do not see anything like "Related Posts." Why?

For instance, in the image below, we do not see any cards.

![There is no related articles, why?](./images/no_related_articles.webp)

**This is because we need two things:**

1. 1\. Your post should have a \`mainTag\` and&#x20;

2. 2\. Posts in your blog should have a \`tags\` array

Look at the example below:

**post.md**

```markdown
---
date: 2025-09-03
slug: docusaurus-relatedposts
title: Displaying related posts below our Docusaurus article
authors: [christophe]
image: /img/social-card.webp
series: Creating Docusaurus components
# highlight-start
mainTag: docusaurus
tags: [component, docusaurus, markdown, react, swizzle]
# highlight-end
---
```

The blog post should have a `mainTag` item, and it should be associated with the most important tag for that post. Let's say, `docusaurus`.

Then, all posts in your blog should have the standard `tags` array, where you can list more than one tag.

The `RelatedPosts` component will then search for `docusaurus` across your posts and display up to six cards — the same result already shown at the top of this article.

## Conclusion

A `mainTag` field, a `posts.ts` helper, a `<Card>` you likely already have, and one theme override: that's the whole recipe for a "Related Posts" block that needs no manual curation. See [Organize Your Docusaurus Content with a Custom Series Component](/blog/docusaurus-series) for articles that follow a deliberate reading order instead of a shared tag, and [Tags management in Docusaurus](/blog/docusaurus-tags) to keep your `mainTag`/`tags` consistent across the whole blog.
