# Display Docusaurus Blog Posts as Cards - A Step-by-Step Guide A series of 2 article(s), in reading order. # 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 `` React component that displays a list of articles sharing the same primary tag (`mainTag`). The process involves building a reusable `` 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 `` component reuses the same `` 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 { const bySlug = new Map(); 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 (

No related posts.

); } return (

Related posts

{related.map((post) => ( ))}
); } ``` ## Overriding the BlogPostItem template We now need to override Docusaurus's **BlogPostItem** template so we can inject our `` 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 ( {children} // highlight-next-line {isBlogPostPage && } ); } ``` 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 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 `` 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. --- # Display Docusaurus Blog Posts as Cards - A Step-by-Step Guide > Learn how to visually showcase your Docusaurus blog posts using customizable card layouts. --- ![Display Docusaurus Blog Posts as Cards - A Step-by-Step Guide](/img/v2/docusaurus_react.webp) **TL;DR** This comprehensive guide demonstrates how to transform Docusaurus's default blog post lists into a visually appealing card-based layout. You'll learn how to implement a reusable multi-part `` component, and then create a custom `` component to display post-specific information. The tutorial walks you through creating a local Docusaurus plugin to override the default `/blog/tags/:tag` route, and a new page component to render the cards. It also covers utility scripts for fetching post metadata and generating slugs. In this article, we'll learn how to create a card from a blog post. If you're [using Docusaurus for your blog](/blog/docusaurus-docker-own-blog), you might be looking for some ways to improve the layout of pages. Instead of displaying posts as a list, let's see how we can display them as **cards**. In this guide, we'll walk through how to create this. You've been just asked to copy/paste files to your site and make a small change in your configuration file. ![Example of a card](./images/cards.webp) This will imply a big number of files but the idea is then to reuse created components / files for later use. ## Using the Reusable Card component of Docux First, we'll retrieve the [Reusable Card Component](https://docuxlab.com/blog/card-component-docusaurus-infima/) of our friend, ![Docux](/img/docux.webp)[Docux](https://github.com/Juniors017). It's a set of files you have to create; let's proceed. > **Information:** > > That one can be retrieved too on the [Docusaurus Community](https://docusaurus.community/knowledge/component-library/new/Card/) site but perhaps not up to date. In your Docusaurus site, please create these files: **src/components/Card/index.tsx** ```typescript import type { CSSProperties, JSX, ReactNode } from "react"; import clsx from "clsx"; // clsx helps manage conditional className names in a clean and concise manner. interface Props { /** Custom classes for the container card */ className?: string; /** Custom styles for the container card */ style?: CSSProperties; /** Content to be included within the card */ children?: ReactNode; /** Shadow under the card. Expected values: low (lw), medium (md), tall (tl) */ shadow?: "lw" | "md" | "tl"; } const Card = ({ className, style, children, shadow }: Props): JSX.Element => { const cardShadow = shadow ? `item shadow--${shadow}` : ""; return (
{children}
); }; export default Card; ``` **src/components/Card/styles.module.css** ```css .truncate p { display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0; overflow-wrap: break-word; max-height: 4.5em; /* 3 lines × 1.5em line-height */ line-height: 1.5em; } ``` **src/components/Card/CardBody/index.tsx** ```typescript import type { CSSProperties, JSX, ReactNode } from "react"; import clsx from "clsx"; import styles from "../styles.module.css"; import { buildTextClasses, type CardTextOptions } from "../utils"; interface Props extends CardTextOptions { /** className for the container card */ className?: string; /** Custom styles for the container card */ style?: CSSProperties; /** Content to be included within the card */ children?: ReactNode; } const CardBody = ({ className, style, children, textAlign, variant, italic = false, noDecoration = false, transform, truncate = false, weight, }: Props): JSX.Element => { const { text, textColor, textItalic, textDecoration, textType, textTruncate, textWeight, } = buildTextClasses( { textAlign, variant, italic, noDecoration, transform, truncate, weight }, styles.truncate, ); return (
{children}
); }; export default CardBody; ``` **src/components/Card/CardFooter/index.tsx** ```typescript import type { CSSProperties, JSX, ReactNode } from "react"; import clsx from "clsx"; import styles from "../styles.module.css"; import { buildTextClasses, type CardTextOptions } from "../utils"; interface Props extends CardTextOptions { className?: string; style?: CSSProperties; children?: ReactNode; } const CardFooter = ({ className, style, children, textAlign, variant, italic = false, noDecoration = false, transform, truncate = false, weight, }: Props): JSX.Element => { const { text, textColor, textItalic, textDecoration, textType, textTruncate, textWeight, } = buildTextClasses( { textAlign, variant, italic, noDecoration, transform, truncate, weight }, styles.truncate, ); return (
{children}
); }; export default CardFooter; ``` **src/components/Card/CardHeader/index.tsx** ```typescript import type { CSSProperties, JSX, ReactNode } from "react"; import clsx from "clsx"; // clsx helps manage conditional className names in a clean and concise manner. import styles from "../styles.module.css"; import { buildTextClasses, type CardTextOptions } from "../utils"; interface Props extends CardTextOptions { /** className for the container card */ className?: string; /** Custom styles for the container card */ style?: CSSProperties; /** Content to be included within the card */ children?: ReactNode; } const CardHeader = ({ className, style, children, textAlign, variant, italic = false, noDecoration = false, transform, truncate = false, weight, }: Props): JSX.Element => { const { text, textColor, textItalic, textDecoration, textType, textTruncate, textWeight, } = buildTextClasses( { textAlign, variant, italic, noDecoration, transform, truncate, weight }, styles.truncate, ); return (
{children}
); }; export default CardHeader; ``` **src/components/Card/CardImage/index.tsx** ```typescript /** * CardImage Component * * Renders an image for a card layout with optional lazy loading and * customizable size. Automatically resolves the image URL using Docusaurus's * `useBaseUrl` hook. Applies default styling via the "card__image" class * and supports additional custom styles. * * Notes: * - Using `width` and `height` helps reduce layout shift by reserving space * for the image before it loads. * - You can also override sizing via CSS or the `style` prop if more flexibility is needed. */ import type { CSSProperties, JSX } from "react"; import clsx from "clsx"; import useBaseUrl from "@docusaurus/useBaseUrl"; interface Props { /** Optional additional CSS classes to apply. */ className?: string; /** Inline styles for the image element. */ style?: CSSProperties; /** Relative or absolute path to the image. */ cardImageUrl: string; /** Alternative text for accessibility. */ alt: string; /** Optional tooltip text shown on hover. */ title?: string; /** If true, enables `loading="lazy"` to defer image loading until visible. */ lazy?: boolean; /** Optional width of the image in pixels. */ width?: number; /** Optional height of the image in pixels. */ height?: number; } const CardImage = ({ className, style, cardImageUrl, alt, title, lazy = true, width, height, }: Props): JSX.Element => { const generatedCardImageUrl = useBaseUrl(cardImageUrl); return ( {alt} ); }; export default CardImage; ``` So right now, you've created six files on your disk. Before going any further in this tutorial, let's first play around with the Card component and see if everything is saved correctly. Please create a new post in your `blog` folder but pay attention to the extension, it should be a `.mdx` file (because we'll add some code that should be interpreted). **blog/card.mdx** ```mdx import Card from "@site/src/components/Card"; import CardFooter from "@site/src/components/Card/CardFooter"; import CardImage from "@site/src/components/Card/CardImage";
``` If you run your blog right now, you should get this: ![Docux card](./images/docux_sample.webp) > **Note:** > > If you want to retrieve the cute dino image, just grab it from here and store it in your `/static/img` folder with `docux.webp` as a name. > > You can reuse any other image for sure. ## Nice, it works. Let's continue with a practical sample In this article, we'll override the standard page with the list of tags. As you know, natively, Docusaurus can display a page with [the list of tags](/blog/docusaurus-tags) used across your articles if you access the **/blog/tags** page. So for my own blog, the page is and you'll get something like this: ![The tags page](./images/tags.webp) By clicking on a specific tag, Docusaurus will show a list of posts for that tag and we'll change this. For instance, this: ![Before our tag override](./images/tag_before.webp) > **Information:** > > It can be nice but ... OK, I have to scroll a lot before seeing all articles and find the one that might interest me. We'll change this to a list of post cards. As an example, on my blog, I'm also writing about [python](https://www.avonture.be/blog/tags/python) and here is how I'm rendering the page: ![The Python tag page](./images/tag_python.webp) This concrete example will allow us to implement two Docusaurus concepts: **the concept of plugins and that of components**. ## Overriding the /blog/tags/tag page by creating a plugin Please create the file here above. **plugins/docusaurus-plugin-tag-route/index.cjs** ```cjs /** * Docusaurus Plugin: tag-route * * Registers the `/blog/tags/` pages rendered by `TagArticlesPage`. * * Why one route per tag instead of a single `/blog/tags/:tag` (TODO 0092): * Docusaurus's `handleBrokenLinks` resolves links against the list of *registered* * routes, and a parameterized path matches nothing there. With a single `:tag` route * every `` in the corpus was reported as broken, which is * why `onBrokenLinks` had to be set to "ignore" site-wide — and a genuinely dead link * then went out silently with it. Enumerating the real tags at build time makes those * links resolvable, so link checking can be switched back on. * * Note this is *not* the blog plugin's own tag listing: that one lives at * `/blog/tags/tags/` (the doubled segment comes from blog/tags.yml's `permalink` * values being prefixed with the `/blog/tags` base), so the two never collide. */ const path = require("path"); const { normalizeUrl } = require("@docusaurus/utils"); const { listTagSlugs } = require("../lib/blog-taxonomy.cjs"); module.exports = function (context) { return { name: "docusaurus-plugin-tag-route", // Routes are enumerated from article frontmatter, so a new tag/series would // otherwise only get a route on the next dev-server restart. Watching the corpus // makes `yarn start` pick it up on save, like the parameterized route used to. getPathsToWatch() { return [path.join(context.siteDir, "blog", "**", "index.{md,mdx}")]; }, async contentLoaded({ actions }) { for (const slug of listTagSlugs(context.siteDir)) { actions.addRoute({ // MUST go through `baseUrl`: under a non-default locale it becomes `/fr/`, and a // hardcoded `/blog/tags/` registers a route the locale-prefixed links never // reach. That mismatch reported 2520 broken links on the `fr` build — the routes // existed as `/blog/tags/` while every link pointed at // `/fr/blog/tags/`. See TODO 0119. path: normalizeUrl([context.baseUrl, "blog/tags", slug]), component: "@site/src/components/Blog/Tags/TagArticlesPage", exact: true, }); } }, }; }; ``` Take a few seconds to read this short script: as you can see that one will tell Docusaurus to no longer use its own standard `/blog/tags/:tag` page but ours (i.e. `src/components/Blog/Tags/TagArticlesPage`). Let's create that one. ## Creating the TagArticlesPage Please create the file below. It's the `TagArticlesPage` function that'll be called by Docusaurus when it displays a specific tag. **src/components/Blog/Tags/TagArticlesPage.tsx** ```typescript import type { JSX } from "react"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useBlogMetadata } from "@site/src/components/Blog/utils/posts"; import { useLocation, matchPath } from "@docusaurus/router"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import Layout from "@theme/Layout"; import Link from "@docusaurus/Link"; import Translate, { translate } from "@docusaurus/Translate"; import styles from "./styles.module.css"; import PostCard from "@site/src/components/Blog/PostCard"; import Head from "@docusaurus/Head"; import FollowFeed from "@site/src/components/FollowFeed"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import { useSourceLocaleUrls } from "@site/src/components/Blog/utils/localeUrls"; type Tag = string | { label: string }; export default function TagArticlesPage(): JSX.Element { const location = useLocation(); // Hooks must run before any early return. const posts = useBlogMetadata(); const tagLabel = useTagLabel(); const { isDefaultLocale, sourceLabel, sourceLocale, sourceUrl } = useSourceLocaleUrls(); const { withBaseUrl } = useBaseUrlUtils(); // The pattern MUST carry `baseUrl`: under the `fr` locale the pathname is `/fr/blog/tags/` // and a hardcoded pattern silently matches nothing, rendering the "not found" branch on a // perfectly valid page (TODO 0119). const { siteConfig } = useDocusaurusContext(); const base = siteConfig.baseUrl.replace(/\/$/, ""); const match = matchPath<{ slug: string }>(location.pathname, { path: `${base}/blog/tags/:slug`, exact: true, }); const rawTag = match?.params?.slug; if (!rawTag) { return (

No tag specified.

Go back to all tags
); } // Local helper, unrelated to `useTagLabel`: it reads the raw value out of a front-matter // entry, which may be a bare string or a `{ label }` object. What comes out is the KEY. const getTagKey = (t: Tag) => (typeof t === "string" ? t : t.label); // Find original tag name based on slug let displayTag = rawTag; for (const post of posts) { const foundTag = (post.tags || []).find( (t) => createSlug(getTagKey(t)) === rawTag ); if (foundTag) { displayTag = getTagKey(foundTag); break; } } // `displayTag` is the front-matter KEY; this is what the reader sees. Resolving it here, once, // also closes the inconsistency noted below: the

used to print the raw key ("ai") while // the feed button right under it printed the real label ("Artificial Intelligence (AI)"). const displayLabel = tagLabel(displayTag); // Filter and sort posts by slug-matched tag (most recent first) const taggedPosts = posts .filter((post) => post.tags?.some((t) => createSlug(getTagKey(t)) === rawTag) ) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Descending if (taggedPosts.length === 0) { return (

{'No articles found with tag "{label}"'}

Browse all tags
); } const feedUrl = `/blog/tags/${rawTag}/rss.xml`; return (

{"Articles tagged: {label}"}

{/* This route is registered by docusaurus-plugin-tag-route, not by the blog plugin, so Docusaurus injects no feed autodiscovery of its own here — same reasoning as src/components/MarkdownAlternate. */} {/* `feedUrl` is a hand-built path to a per-locale static file, so it needs the prefix; the second points at the source language, which covers the whole blog. Helmet de-duplicates on `href`, so both are kept. */} {!isDefaultLocale && ( )}
{taggedPosts.map((post) => ( ))}
); } ``` As you can see at the very top of the `TagArticlesPage.tsx` file, we're importing a few files. Some already exist in your Docusaurus site; some should be created. There are three files we should create ## The slug generator **src/components/Blog/utils/slug.ts** ```typescript /** * Converts a string into a URL-friendly slug. * * This function: * - Converts the string to lowercase * - Normalizes accented characters (e.g., accented e to plain e) * - Removes diacritics and special characters * - Replaces spaces with hyphens * - Collapses multiple hyphens into one * - Trims leading and trailing hyphens * * @param text - The input string to convert. * @returns The slugified version of the input. */ export function createSlug(text: string): string { return text .replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase to kebab-case .toLowerCase() .normalize("NFD") // Decomposes accented characters .replace(/[\u0300-\u036f]/g, "") // Removes diacritics .replace(/[^a-z0-9\s-]/g, "") // Removes special characters .replace(/\s+/g, "-") // Replaces spaces with hyphens .replace(/-+/g, "-") // Collapses multiple hyphens .replace(/^-+|-+$/g, ""); // Trims leading/trailing hyphens } ``` ## The getBlogMetaData function We need a function to loop all our existing blog posts (all `.md` or `.mdx` files under the `blog` folder) and collect information like the post title, image, slug and so on. **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 './getBlogMetadata'; * const posts = getBlogMetadata(); * ``` * * ⚠️ Note: * This function is intended for use in static site generation or client-side rendering * where Webpack's `require.context` is available. */ export interface BlogPostMetadata { title: string; description?: string; image?: string; draft: boolean; unlisted: boolean; permalink: string; tags: string[]; mainTag: string | null; authors: string[]; date?: string; series: string | null; } const posts = require.context("../../../../blog", true, /\.mdx?$/); export function getBlogMetadata(): BlogPostMetadata[] { return posts .keys() .map((key): BlogPostMetadata => { const post = posts(key) as { frontMatter: Record }; const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, ""); let permalink: string; if (typeof post.frontMatter.slug === "string") { permalink = post.frontMatter.slug.startsWith("/") ? post.frontMatter.slug : `/blog/${post.frontMatter.slug.replace(/^\//, "")}`; } else { permalink = `/blog/${dir}/`; } let imageUrl = post.frontMatter.image as string | undefined; if (imageUrl && imageUrl.startsWith("./")) { imageUrl = `/blog/${dir}/${imageUrl.replace("./", "")}`; } return { title: post.frontMatter.title as string, description: post.frontMatter.description as string | undefined, image: imageUrl, draft: Boolean(post.frontMatter.draft), unlisted: Boolean(post.frontMatter.unlisted), permalink, tags: (post.frontMatter.tags as string[]) || [], mainTag: (post.frontMatter.mainTag as string) || null, authors: (post.frontMatter.authors as string[]) || [], date: post.frontMatter.date as string | undefined, series: (post.frontMatter.series as string) || null, }; }) .filter(Boolean); } ```` ## Our PostCard component And, finally, we need our PostCard component; the one that will display a blog post as a card. You can see, in the top of the code, that we're importing a few parts of the [Card component](https://docusaurus.community/knowledge/component-library/new/Card/) of Docux. **src/components/Blog/PostCard/index.tsx** ```typescript /** * ♣️ PostCard component * * A reusable Docusaurus component that displays a blog post preview card. * It shows the post image (with fallback), title (as a link), and description. * * Accessibility: * - The title is wrapped in a semantic link with an aria-label for screen readers. * - The image includes alt and title attributes. * * Styling: * - Uses Infima utility classes for layout and spacing. * - Custom styles can be applied via `styles.module.css`. * * Location: src/components/Blog/PostCard/index.tsx */ import type { JSX } from "react"; import Card from "@site/src/components/Card"; import CardBody from "@site/src/components/Card/CardBody"; import CardImage from "@site/src/components/Card/CardImage"; import Link from "@docusaurus/Link"; import styles from "./styles.module.css"; interface FormattedDateProps { date?: string; layout: "big" | "small"; } /** Renders a formatted date string. */ function FormattedDate({ date, layout }: FormattedDateProps): JSX.Element | null { if (!date) { return null; } return (

{new Date(date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", })}

); } interface Post { permalink: string; image?: string; title: string; description?: string; date?: string; } interface Props { post: Post; layout?: "big" | "small"; defaultImage?: string; } export default function PostCard({ post, layout = "big", defaultImage = "/img/default.jpg", }: Props): JSX.Element { const { permalink, image, title, description, date } = post; if (layout === "small") { return (
{title}

{title}

{description && (
{description}
)}
Read more
); } // Default "big" layout return (

{title} →

{description &&

{description}

}
); } ``` Finally, we'll create a CSS file to allow us to do some customization: **src/components/Blog/PostCard/styles.module.css** ```css .description { font-size: 12px; } .date { color: #888; font-size: 0.95em; margin-bottom: 8; font-style: italic; } ``` ## We need to load our plugin We're almost done. Remember, the very first file we've created was `plugins/docusaurus-plugin-tag-route/index.cjs`; a plugin. Please edit your existing `docusaurus.config.js` file like below illustrated: **docusaurus.config.js** ```javascript import pluginTagRoute from "./plugins/docusaurus-plugin-tag-route/index.cjs"; const config = { // [...] plugins: [ // [...] [pluginTagRoute, {}], ], // [...] }; ``` i.e. 1. You have to import the plugin at the top of your file 2. Then in the `plugins` section, you have to add a new line to load your imported plugin. We're almost finished! > **Caution:** > > Please stop and restart your Docusaurus server. On my case ([I'm using Docker](/blog/running-docusaurus-with-docker)), I just need to stop and restart my container. If everything is going fine after the restart (no error message), please visit your website tags page and open a tag. You should now see your posts as cards. Congratulations! In [next articles](/blog/docusaurus-relatedposts), we'll reuse this concept of post cards. Stay tuned! ## Extra If you want your blog posts to be listed from the newest one to the oldest, please use this script. But make sure every blog post you have has a `date` field in its YAML front matter. **src/components/Blog/Tags/TagArticlesPage.tsx** ```typescript import { createSlug } from "@site/src/components/Blog/utils/slug"; import { getBlogMetadata } from "@site/src/components/Blog/utils/posts"; import { useLocation } from "@docusaurus/router"; import Layout from "@theme/Layout"; import Link from "@docusaurus/Link"; import PostCard from "@site/src/components/Blog/PostCard"; function getTagFromPathname(pathname: string): string | null { const match = pathname.match(/\/blog\/tags\/([^/]+)/); return match ? decodeURIComponent(match[1]) : null; } export default function TagArticlesPage() { const location = useLocation(); const rawTag = getTagFromPathname(location.pathname); if (!rawTag) { return (

No tag specified.

Go back to all tags
); } const posts = getBlogMetadata(); // Find original tag name based on slug const allTags = Array.from(new Set(posts.flatMap((post) => post.tags || []))); const displayTag = allTags.find((t) => createSlug(String(t)) === rawTag) || rawTag; // highlight-start // Filter and sort posts by slug-matched tag (most recent first) const taggedPosts = posts .filter((post) => post.tags?.some((t) => createSlug(String(t)) === rawTag)) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Descending // highlight-end if (taggedPosts.length === 0) { return (

No articles found with tag "{displayTag}"

Browse all tags
); } return (

Articles tagged: {displayTag}

{taggedPosts.map((post) => ( ))}
); } ``` ---