frontend / nextjs
SEO et Metadata API
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.tset unrobots.tsgé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 / export | Rôle |
|---|---|
export const metadata | Métadonnées statiques (layout racine, page fixe) |
generateMetadata() | Métadonnées dynamiques, par entité (article, produit) |
app/sitemap.ts | Génère /sitemap.xml avec toutes les URLs, y compris dynamiques |
app/robots.ts | Gé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
// 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 },
};// 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" };
}// 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,
];
}// 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",
};
}// 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.templatedans le layout racine factorise le suffixe de titre pour toutes les pages.generateMetadataaccepte les mêmesparamsque la page pour du SEO dynamique par entité.sitemap.tsetrobots.tsremplacent 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
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.