SEO et métadonnées

Stocker un objet seo par contenu, l'éditer dans l'admin et le rendre en balises Next.js.

Le SEO est une capacité partagée du core, pas un module. Les données sont par contenu : elles vivent dans la table de chaque module. Le core fournit le contrat de données, le builder de métadonnées et les composants d'édition.

Activer

LRVO_SEO=true
LRVO_NAME=Mon site
LRVO_SEO_SEPARATOR=-
LRVO_SEO_TITLE_TEMPLATE=%title% %sep% %sitename%

Le flag est lu côté serveur uniquement. Côté client, l'éditeur le reçoit par useLrvoConfig().seo.

1. Stocker

Ajouter une colonne JSONB à la table du module, ou une clé dans un data JSONB existant.

ALTER TABLE lrvo_blog_posts ADD COLUMN seo JSONB DEFAULT '{}'::jsonb;

La forme est celle d'EMPTY_SEO :

{
  title, description, canonical,
  keywords: [],
  robots: { index: true, follow: true, noarchive: false },
  openGraph: { title, description, image: { id, url, alt } },
  twitter: { card: 'summary_large_image', title, description },
}

Ne pas inventer une autre forme : les composants d'édition, le score et le builder attendent celle-ci.

2. Éditer

Rendre SeoFields sous la zone de contenu, conditionné par le flag.

'use client';
import { SeoFields } from '@lrvo/core/shared/components';
import { useLrvoConfig } from '@lrvo/core/features/admin/components';

export function PostForm({ formData, setField, urlPreview }) {
  const { seo: seoEnabled, siteName, seoSeparator, seoTitleTemplate } = useLrvoConfig();

  return (
    <>
      {/* champs du contenu */}

      {seoEnabled && (
        <SeoFields
          value={formData.seo || {}}
          onChange={(seo) => setField('seo', seo)}
          sourcePath={urlPreview}
          fallbackTitle={formData.title}
          siteName={siteName}
          separator={seoSeparator}
          titleTemplate={seoTitleTemplate}
        />
      )}
    </>
  );
}

SeoFields apporte les champs, la jauge de score en temps réel et les aperçus du rendu sur Google, X, Facebook, LinkedIn, Pinterest et Slack.

sourcePath sert de placeholder au canonical et d'URL d'aperçu. fallbackTitle alimente aperçus et score quand le titre SEO est vide.

L'image de partage passe par le sélecteur de @lrvo/module-medias, qui se masque quand le module est absent.

3. Lister

Ajouter une colonne de score à la table d'admin.

{seoEnabled && (
  <SeoScoreBadge
    seo={row.seo}
    fallbackTitle={row.title}
    hasPath
    siteName={siteName}
    separator={seoSeparator}
    titleTemplate={seoTitleTemplate}
  />
)}

La requête de liste doit sélectionner la colonne seo. Sans elle, la pastille affiche un score nul pour tout le monde.

4. Rendre

import { buildSeoMetadata, isSeoEnabled } from '@lrvo/core/seo';

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = await getPublicPostBySlug(slug);
  if (!post) return {};

  if (!isSeoEnabled()) return { title: post.title };

  return buildSeoMetadata(post.seo, {
    path: `/blogue/${slug}`,
    fallbackTitle: post.title,
    fallbackDescription: post.excerpt,
    fallbackImage: post.coverUrl,
  });
}

buildSeoMetadata émet title, description, keywords, alternates.canonical, robots, openGraph et twitter. Les clés vides sont omises : pas de balise inutile dans le <head>. Les URL relatives sont rendues absolues avec getPublicBaseUrl().

5. Retomber sur le contenu

Un contenu dont la zone SEO est vide n'a pas de raison de sortir sans description ni image de partage : le module a déjà de quoi les dériver. C'est le rôle de fallbackDescription et fallbackImage, que buildSeoMetadata n'applique que si le champ correspondant n'a pas été saisi.

Passez ces deux valeurs aux quatre endroits de ce guide, pas seulement au rendu : SeoFields les affiche en filigrane, SeoScoreBadge et computeSeoScore les comptent dans le score. Sans cela l'éditeur signale une description manquante alors que la page publique en émet une.

@lrvo/module-posts en donne un exemple complet : il dérive la description d'un champ text ou editor et l'image d'un champ thumbnail ou image, avec un drapeau de configuration pour désigner explicitement la source. Voir Repli SEO automatique.

Tokens

Le titre et la description acceptent des tokens résolus au rendu.

TokenValeur
%title%Titre du contenu, le fallbackTitle.
%sitename%LRVO_NAME.
%sep%LRVO_SEO_SEPARATOR.
  • Titre SEO vide : LRVO_SEO_TITLE_TEMPLATE s'applique, ce qui donne Ma page - Mon site.
  • Titre SEO renseigné : il sert lui-même de gabarit et peut contenir des tokens. Sans token, il est pris littéralement.
  • Un séparateur orphelin laissé par un token vide est retiré automatiquement.

Score

computeSeoScore produit un score sur 100 et le détail des contrôles : longueurs de titre et de description, image de partage, canonical, indexabilité, texte social, mots-clés.

C'est un indicateur d'aide à la rédaction, pas un audit technique. Un score de 100 ne garantit pas un bon référencement, et un score moyen sur une page volontairement noindex n'est pas un problème.

Les paliers de couleur sont : vert à partir de 80, ambre à partir de 50, rouge en dessous.

Sitemap

Un sitemap se construit côté serveur, en lisant directement la base par les helpers ./public du module. Ne pas faire de fetch vers ses propres routes API : voir Frontières serveur/client.

import { getPublicBaseUrl } from '@lrvo/core/shared/config';
import { getPublicPosts } from '@lrvo/module-posts/public';   // voir 06-modules/posts/api.md

export default async function sitemap() {
  const base = getPublicBaseUrl();
  const posts = await getPublicPosts({ postType: 'blogue' });

  return posts.map((p) => ({
    url: `${base}/blogue/${p.slug}`,
    lastModified: p.updated_at,
  }));
}

Pages sans contenu SEO

Pour une page technique ou d'admin, qui n'a pas d'objet seo stocké, utiliser @lrvo/core/shared/metadata plutôt que le builder. Voir Métadonnées.

Pièges

Ne pas lire LRVO_SEO depuis un composant client. Une variable sans préfixe NEXT_PUBLIC_ retourne undefined, et la zone SEO disparaîtrait silencieusement. Passer par useLrvoConfig().

Le canonical par défaut vient de ctx.path. L'oublier laisse chaque page sans canonical, ce qui pose problème dès qu'une même ressource est joignable par deux URL.

Les aperçus sociaux ne rafraîchissent pas immédiatement. Les plateformes mettent en cache l'image de partage ; utiliser leur outil de débogage pour forcer une relecture.

Sur cette page