<!--
  canonical-url: https://www.avonture.be/fr/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.
-->

# Afficher les articles liés sous nos articles Docusaurus

> Améliorez votre blog Docusaurus en affichant les articles liés sous chaque article pour faciliter la découverte de contenu.

---
![Afficher les articles liés sous nos articles Docusaurus](/img/v2/docusaurus_react.webp)

**TL;DR**

Ce guide explique comment ajouter une section « Articles liés » sous vos articles de blog Docusaurus. Vous allez apprendre à créer un composant React `<RelatedPosts>` personnalisé qui affiche une liste d'articles partageant le même tag principal (`mainTag`). La démarche consiste à construire un composant `<Card>` réutilisable, à créer un script utilitaire pour récupérer et parser les données des articles, et enfin à surcharger le thème `BlogPostItem` de Docusaurus pour injecter votre nouveau composant en bas de chaque article.

Docusaurus peut afficher une liste de tags, mais pas d'articles liés. *Ce `mainTag` n'est utile que si vos tags sont cohérents ; [Tags management in Docusaurus](/blog/docusaurus-tags) présente le script de nettoyage que j'utilise pour qu'ils le restent.*

Quand vous parcourez mon blog, vous voyez une liste d'**articles liés** sous chaque article. C'est un composant que j'ai codé avec l'aide de l'IA. *Pour les articles qui suivent un ordre de lecture voulu plutôt qu'un tag commun, voyez [Organize Your Docusaurus Content with a Custom Series Component](/blog/docusaurus-series).*

L'objectif de notre composant est d'afficher quelque chose comme ceci :

![Articles de blog liés](./images/related.webp)

Voici le résultat final une fois le tout branché : jusqu'à six cartes, choisies parmi les articles partageant le même `mainTag`.

![Le résultat final](./images/final.webp)

## Pourquoi ça fonctionne {#why-it-works}

* La correspondance se fait sur `mainTag`, un seul champ de front matter personnalisé — pas de sélection manuelle, pas de pondération de mots-clés, juste « même sujet principal ».
* Le composant `<RelatedPosts>` réutilise le même `<Card>` que vous construiriez pour n'importe quelle liste d'articles, donc il n'y a qu'une seule mise en page de carte à maintenir sur tout le site.
* C'est une surcharge de thème, pas un fork : `BlogPostItem` affiche toujours tout ce que Docusaurus fait normalement, on injecte simplement un composant en plus tout en bas.

## Il nous faut un composant Card {#we-need-a-card-component}

Suivez les instructions du chapitre [Utiliser le composant Card réutilisable de Docux](/blog/docusaurus-cards#using-the-reusable-card-component-of-docux).

Vous devrez créer jusqu'à cinq fichiers dans un dossier `src/components/Card/` (et ses sous-dossiers).

Revenez à cet article une fois les cinq fichiers créés.

## Il nous faut un moyen d'extraire les informations des articles {#we-need-a-way-to-extract-information-from-blog-posts}

Si ce n'est pas déjà fait, créez le fichier `src/components/Blog/utils/posts.ts`. Il contiendra une fonction utilitaire réutilisable par plusieurs composants.

Cette fonction va scanner chaque fichier Markdown avec une extension `.md` ou `.mdx` dans le sous-dossier `blog`.

Pour chaque fichier, le script va regarder le front matter YAML et utiliser certaines de ses propriétés. Par exemple :

* Si l'article a `draft: true` ou `unlisted: true`, il sera ignoré.
* Si l'article a un `slug`, il sera utilisé. Sinon, le slug sera généré par le code.
* Si l'article a une image associée, elle sera utilisée. Sinon, une image par défaut sera utilisée.
* Ensuite, la fonction retournera la liste des articles et leurs propriétés.

Certaines propriétés, comme `mainTag`, sont personnalisées.

Copiez-collez donc le contenu du fichier ci-dessous et créez le fichier `src/components/Blog/utils/posts.ts` dans la structure de votre projet.

**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,
    };
  });
}
````

## Notre composant RelatedPosts {#our-relatedposts-component}

Créez maintenant le fichier `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>
  );
}
```

## Surcharger le template BlogPostItem {#overriding-the-blogpostitem-template}

Nous devons maintenant surcharger le template **BlogPostItem** de Docusaurus pour pouvoir injecter notre composant `<RelatedPosts>` en bas de chaque article.

Dans un terminal, lancez `yarn docusaurus swizzle @docusaurus/theme-classic BlogPostItem`, puis sélectionnez `Javascript`, ensuite `Eject`, et enfin `YES`.

Beaucoup de fichiers seront créés dans le dossier `src/theme/BlogPostItem` de votre projet Docusaurus. On peut supprimer sans risque tous les fichiers sauf `src/theme/BlogPostItem/index.js`, que nous devons éditer.

Supprimez donc tous les fichiers et dossiers sous `src/theme/BlogPostItem` à l'exception du fichier `index.js`.

Dans le code ci-dessous, les lignes surlignées sont celles que nous devons ajouter.

**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>
  );
}
```

Maintenant, comme nous venons d'introduire une surcharge, nous devons redémarrer notre serveur Docusaurus pour que les changements prennent effet.

> **Lancez npm run start:**
>
> Si vous faites tourner Docusaurus en local, lancez simplement `npm run start` dans votre console.
> Si, comme moi, vous faites tourner Docusaurus avec Docker, arrêtez le container et démarrez-en un nouveau.

## Éditer nos articles de blog {#editing-our-blog-posts}

Nous avons fait toute la configuration nécessaire, mais quand vous visitez votre blog, vous ne voyez rien qui ressemble à « Articles liés ». Pourquoi ?

Par exemple, dans l'image ci-dessous, on ne voit aucune carte.

![Il n'y a aucun article lié, pourquoi ?](./images/no_related_articles.webp)

**C'est parce qu'il nous faut deux choses :**

1. 1\. Votre article doit avoir un \`mainTag\` et&#x20;

2. 2\. Les articles de votre blog doivent avoir un tableau \`tags\`

Regardez l'exemple ci-dessous :

**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
---
```

L'article doit avoir un élément `mainTag`, associé au tag le plus important pour cet article. Disons `docusaurus`.

Ensuite, tous les articles de votre blog doivent avoir le tableau `tags` standard, dans lequel vous pouvez lister plusieurs tags.

Le composant `RelatedPosts` va alors chercher `docusaurus` dans vos articles et afficher jusqu'à six cartes — le résultat déjà montré en haut de cet article.

## Conclusion {#conclusion}

Un champ `mainTag`, une fonction utilitaire `posts.ts`, un `<Card>` que vous avez probablement déjà, et une surcharge de thème : voilà toute la recette d'un bloc « Articles liés » qui ne demande aucune sélection manuelle. Voyez [Organize Your Docusaurus Content with a Custom Series Component](/blog/docusaurus-series) pour les articles qui suivent un ordre de lecture voulu plutôt qu'un tag commun, et [Tags management in Docusaurus](/blog/docusaurus-tags) pour garder vos `mainTag`/`tags` cohérents sur tout le blog.
