Retour au cours

frontend / nextjs

SEO et Metadata API

Leçon 201 exercice

Explication

Ce que vous allez apprendre

  • Définir des métadonnées globales (titre, description) dans le layout racine
  • Utiliser un template de titre pour éviter de répéter le nom du site sur chaque page
  • Générer des métadonnées dynamiques par entité avec generateMetadata
  • Créer un sitemap.ts et un robots.ts générés automatiquement à partir des données
  • Enrichir les résultats de recherche avec des données structurées JSON-LD

Dans quel contexte ?

Une équipe marketing se plaint qu'un lien vers un article de blog partagé sur LinkedIn n'affiche ni titre, ni image, ni description — juste l'URL brute. En creusant, on découvre que metadataBase n'a jamais été renseigné dans le layout racine, rendant invalides toutes les URLs relatives des images Open Graph générées par generateMetadata. Cette leçon couvre ce cas précis ainsi que l'ensemble du système de métadonnées de Next.js, du titre de la page d'accueil jusqu'aux données structurées d'une fiche produit.

D'abord, une question toute simple

À quoi sert une page si personne ne la trouve ? Un site peut être rapide et sécurisé ; si Google ne comprend pas son contenu, ou si un lien partagé sur les réseaux sociaux affiche un aperçu vide, tout le travail des leçons précédentes reste invisible.

Commençons par le plus basique : le titre de chaque page. L'objet metadata exporté depuis layout.tsx ou page.tsx définit le titre, la description et d'autres informations lues par les moteurs de recherche.

Pour éviter de répéter le nom du site sur chaque page, un mécanisme de template existe. En définissant title.template: "%s | Mon App" dans le layout racine, chaque page enfant n'a plus qu'à fournir sa propre partie du titre — Next.js compose le reste automatiquement, un peu comme les layouts imbriqués vus au début du cours.

Une fois ce socle commun posé, il reste un problème : un article de blog ou une fiche produit a besoin d'un titre et d'une image UNIQUES. Une métadonnée statique, identique pour toutes les pages, ne peut pas répondre à ce besoin.

La solution s'appelle generateMetadata. Elle fonctionne comme la page elle-même : elle reçoit les mêmes params, va chercher les données correspondantes, et construit un titre et une image de partage propres à CETTE page précise.

Fichier / exportRôle
export const metadataMétadonnées statiques (layout racine, page fixe)
generateMetadata()Métadonnées dynamiques, par entité (article, produit)
app/sitemap.tsGénère /sitemap.xml avec toutes les URLs, y compris dynamiques
app/robots.tsGénère /robots.txt, exclut les zones privées de l'indexation

Prérequis

Cette leçon suppose que tu es à l'aise avec les routes dynamiques et generateStaticParams vus précédemment : generateMetadata reçoit exactement les mêmes params que la page correspondante.

Maintenant que chaque page a ses métadonnées, il faut aussi aider les robots à les découvrir. sitemap.ts génère automatiquement la liste de toutes les URLs du site, y compris celles créées dynamiquement à partir de données.

À l'inverse, certaines pages ne doivent PAS être indexées. robots.ts indique explicitement les zones à exclure, comme un dashboard privé ou une route d'API.

Pour aller plus loin, un dernier outil enrichit l'apparence dans les résultats de recherche : le JSON-LD. En décrivant un produit selon le vocabulaire standardisé schema.org (prix, disponibilité, avis), on permet à Google d'afficher des informations enrichies directement dans les résultats.

Le piège le plus fréquent à connaître avant de pratiquer : oublier de renseigner metadataBase dans le layout racine. Sans lui, les URLs relatives des images Open Graph deviennent invalides une fois partagées, et l'aperçu sur les réseaux sociaux n'affiche aucune image.

Piège fréquent

Sans metadataBase: new URL("https://monapp.com") dans le layout racine, une image Open Graph définie avec un chemin relatif comme /cover.jpg ne se résout jamais en URL absolue valide. Résultat concret : un lien partagé sur LinkedIn, X ou WhatsApp n'affiche aucun aperçu visuel, seulement le titre et l'URL brute.

Cette dernière leçon boucle le cours. Elle montre comment rendre visible et bien présenté tout ce qui a été construit depuis la toute première leçon sur l'App Router.

Commandes & code

SEO et Metadata API

tsx
// app/layout.tsx — métadonnées globales par défaut
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://monapp.com"),
  title: {
    default: "Mon App — Accueil",
    template: "%s | Mon App", // les pages enfants n'ont qu'à définir "title"
  },
  description: "La meilleure app pour gérer vos projets.",
  openGraph: {
    type: "website",
    locale: "fr_FR",
    siteName: "Mon App",
  },
  twitter: { card: "summary_large_image" },
  robots: { index: true, follow: true },
};
tsx
// app/blog/[slug]/page.tsx — métadonnées dynamiques + résolution de l'image parente
import type { Metadata, ResolvingMetadata } from "next";

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  const previousImages = (await parent).openGraph?.images ?? [];

  return {
    title: post.title, // devient "Titre de l'article | Mon App" via le template
    description: post.excerpt,
    openGraph: {
      title: post.title,
      images: [post.coverImage, ...previousImages],
    },
    alternates: {
      canonical: `/blog/${slug}`,
    },
  };
}

async function getPost(slug: string) {
  return { title: "Article", excerpt: "...", coverImage: "/cover.jpg" };
}
tsx
// app/sitemap.ts — génère /sitemap.xml automatiquement
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await fetch("https://api.example.com/posts").then((r) => r.json());

  const postEntries: MetadataRoute.Sitemap = posts.map((post: { slug: string; updatedAt: string }) => ({
    url: `https://monapp.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly",
    priority: 0.7,
  }));

  return [
    { url: "https://monapp.com", lastModified: new Date(), priority: 1 },
    { url: "https://monapp.com/blog", lastModified: new Date(), priority: 0.9 },
    ...postEntries,
  ];
}
tsx
// app/robots.ts — génère /robots.txt automatiquement
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api"] },
    ],
    sitemap: "https://monapp.com/sitemap.xml",
  };
}
tsx
// JSON-LD pour les rich snippets (données structurées)
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    image: product.image,
    offers: {
      "@type": "Offer",
      price: product.price,
      priceCurrency: "EUR",
      availability: "https://schema.org/InStock",
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <ProductDetails product={product} />
    </>
  );
}

async function getProduct(id: string) {
  return { name: "Produit", image: "/p.jpg", price: 49.99 };
}

Résumé

  • title.template dans le layout racine factorise le suffixe de titre pour toutes les pages.
  • generateMetadata accepte les mêmes params que la page pour du SEO dynamique par entité.
  • sitemap.ts et robots.ts remplacent les fichiers statiques et se régénèrent avec les données.
  • Le JSON-LD (schema.org) améliore l'apparence dans les résultats de recherche (rich snippets).

Exercices pratiques

1 disponible
1

Mission : l'article partagé sur LinkedIn sans aucune image

Objectif : Corriger un metadataBase manquant qui invalide les images Open Graph et mettre en place les métadonnées dynamiques et l'exclusion d'indexation manquantes.

Contexte

L'équipe marketing signale qu'un lien vers /blog/nouveautes-2026 partagé sur LinkedIn n'affiche ni titre personnalisé, ni image, ni description — seulement l'URL brute. Le layout racine exporte bien metadata avec un title.template, mais sans metadataBase. La page app/blog/[slug]/page.tsx définit son openGraph.images avec le chemin relatif post.coverImage (par ex. /cover.jpg). Par ailleurs, /dashboard (zone privée) apparaît actuellement dans les résultats Google, faute de configuration explicite pour l'exclure.

Résoudre l’exercice →