SEO technique d’un site Next.js : metadata, sitemap, JSON-LD et images de partage
Ce que j’ai mis en place pour le référencement, avec l’App Router de Next.js 16 : métadonnées par page, sitemap et robots générés depuis les données, données structurées, une image de partage par article, un flux RSS, un llms.txt, et les pièges rencontrés en chemin.
Un site Next.js part avec un avantage pour le référencement, puisque ses pages arrivent en HTML complet. Ce n’est pourtant qu’un point de départ, et le reste se construit page par page : titres, descriptions, URL canoniques, sitemap, données structurées et images de partage. Voici ce que j’ai mis en place sur ce portfolio avec l’App Router de Next.js 16, extraits de code à l’appui, et les pièges que j’ai rencontrés en chemin.
Que lit Google sur un site Next.js ?
Google exécute le JavaScript, mais il indexe d’abord le HTML qu’il reçoit, et un rendu entièrement côté client retarde ou fragilise cette indexation. Avec l’App Router, les pages sont rendues côté serveur ou générées au build, si bien que le contenu, les balises meta et les données structurées sont présents dès la première réponse.
Le mode de rendu reste un choix page par page, détaillé dans le guide SSR, ISR ou statique. Ce portfolio est entièrement statique, car son contenu ne change qu’au déploiement.
Des métadonnées propres à chaque page
Dans l’App Router, les métadonnées passent par l’objet metadata ou la fonction generateMetadata, exportés depuis un layout ou une page. Le composant next/head de l’ancien Pages Router n’y a aucun effet. Le layout racine fixe les valeurs communes, dont metadataBase pour que les URL relatives deviennent absolues et un modèle de titre qui ajoute le nom du site à chaque page :
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL(SEO_CONFIG.baseUrl),
title: {default: HOME_TITLE, template: `%s — ${SEO_CONFIG.name}`},
description: HOME_DESCRIPTION,
};Une page dynamique calcule ensuite les siennes à partir de ses données. Dans Next.js 16, params est une Promise qu’il faut attendre :
// app/blog/[slug]/page.tsx
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {slug} = await params;
const post = getBlogPost(slug);
if (!post) return {};
const url = `${SEO_CONFIG.baseUrl}/blog/${slug}`;
return {
title: pageTitle(post.title),
description: post.metaDescription,
alternates: {canonical: url},
};
}Google affiche environ 60 caractères d’un titre. Le helper pageTitle le prend en compte : quand le titre et le suffixe dépassent cette limite, il renvoie {absolute: title}, qui court-circuite le modèle et garde le titre seul. Chaque page publique a aussi sa propre description, d’au plus 160 caractères, et sa canonical, qui évite qu’une même page soit indexée sous plusieurs URL.
Sitemap et robots générés depuis les données
Les fichiers sitemap.ts et robots.ts de l’App Router produisent sitemap.xml et robots.txt depuis le code. Le sitemap se construit à partir des mêmes données que les pages, de sorte qu’un nouvel article y apparaît automatiquement, et sa date lastModified reprend la date de mise à jour de l’article quand il en a une :
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
const blogRoutes = blogPosts.map((post) => ({
url: `${BASE_URL}/blog/${post.slug}`,
lastModified: getLastModified(post), // update date, else publication date
}));
return [...staticRoutes, ...blogRoutes, ...projectRoutes];
}Le robots.txt autorise toutes les pages et indique l’adresse du sitemap. Une date lastModified exacte est utile, car elle signale à Google les pages qui méritent d’être explorées de nouveau ; une date qui change à chaque build perd au contraire toute valeur.
Comment ajouter des données structurées ?
Les données structurées décrivent le contenu d’une page dans le vocabulaire de schema.org, au format JSON-LD. Elles permettent aux moteurs de recherche d’identifier une personne, un article ou un fil d’Ariane, et de les afficher sous forme de résultats enrichis. Sur ce site, chaque type sort d’un builder unique dans lib/seo.ts :
- Person et ProfilePage sur l’accueil, avec le nom, le métier, la localité et les profils LinkedIn et GitHub ;
- BlogPosting sur chaque article, avec ses dates de publication et de mise à jour, son auteur et son image ;
- BreadcrumbList sur les articles et les projets, pour le fil d’Ariane.
Un petit composant insère ces objets dans la page. Il échappe le caractère <, pour qu’un contenu contenant une balise ne puisse pas refermer le script :
// components/JsonLd.tsx
export default function JsonLd({data}: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{__html: JSON.stringify(data).replace(/</g, '\\u003c')}}
/>
);
}Le Rich Results Test de Google valide le résultat page par page. Il signale notamment les dates incomplètes : schema.org attend une date et une heure ISO 8601 avec un fuseau horaire, et une simple date « 2026-10-03 » ne suffit pas.
Une image de partage par article
Quand un article est partagé sur LinkedIn ou dans une messagerie, l’aperçu affiche l’image Open Graph de la page. Un fichier opengraph-image.tsx placé dans le dossier d’une route la génère avec ImageResponse, à partir de JSX et d’un sous-ensemble de CSS (flexbox, positionnement absolu). Combiné à generateStaticParams, il produit au build une image par article, avec son titre et sa catégorie, dans les polices et les couleurs du site.
Deux contraintes de ImageResponse sont à connaître. Seules les polices TTF, OTF et WOFF sont acceptées, et le WebP n’est pas décodé, d’où une version JPEG du portrait réservée à ces images. Une fois en ligne, le LinkedIn Post Inspector affiche l’aperçu réel et force LinkedIn à rafraîchir son cache.
RSS et llms.txt : être lu au-delà de Google
Un flux RSS reste le moyen le plus simple pour qu’un agrégateur ou un lecteur suive un blog. Ici, c’est un route handler statique (dynamic = "force-static") qui construit le XML au build, déclaré dans les métadonnées par alternates.types pour que les navigateurs et les agrégateurs le découvrent.
Le fichier llms.txt suit une convention récente, qui résume un site en Markdown à l’intention des assistants IA. Il est généré de la même façon, depuis les données des projets et des articles, et ne peut donc pas diverger du contenu. Le robots.txt laisse par ailleurs passer les robots des assistants IA, puisque l’objectif est justement d’être cité.
Les pièges à éviter
- La fusion des métadonnées est superficielle : un openGraph défini sur une page remplace entièrement celui du layout, image comprise, et il en va de même pour alternates. Une base partagée, que chaque page étend, évite de perdre ces valeurs en route.
- Un fichier opengraph-image n’est utilisé que si l’openGraph de la page ne déclare pas lui-même d’images ; une base qui en contient une masque donc silencieusement l’image générée.
- L’URL de base mal renseignée en production fausse d’un coup les canonicals, le sitemap, le JSON-LD et les images de partage : mieux vaut la lire à un seul endroit, avec une valeur de repli explicite.
- La balise meta keywords est ignorée par Google depuis des années, et la supprimer allège le code sans rien perdre.
- Deux pages qui partagent le même titre ou la même description se concurrencent dans les résultats de recherche.
Que vérifier après la mise en ligne ?
- la Search Console : soumettre le sitemap, puis demander l’indexation des pages nouvelles ou réécrites ;
- le Rich Results Test sur une page de chaque type ;
- le W3C Feed Validator pour le flux RSS ;
- le LinkedIn Post Inspector sur un article, pour l’image de partage.
Viennent ensuite les mesures dans la durée, avec les pages indexées, les requêtes qui amènent des visites et les Core Web Vitals. Elles disent ce qui fonctionne vraiment, et c’est sur elles que se décident les ajustements suivants.