Next.js App Router offre des outils SEO intégrés puissants. Mais la plupart des tutoriels ne font qu'effleurer la surface. Voici une implémentation complète couvrant les métadonnées, les images Open Graph, les données structurées et les sitemaps.
Prérequis
- Next.js 14+ avec App Router
- Notions de base sur les métadonnées et le SEO
generateMetadata — SEO dynamique par page
La fonction generateMetadata remplace l'ancien composant Head. Elle s'exécute côté serveur et prend en charge la récupération de données asynchrone :
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
type Props = {
params: Promise<{ locale: string; slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug, locale);
if (!post) return { title: "Article introuvable" };
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://votresite.com";
const canonical = `${siteUrl}/${locale}/blog/${slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical,
languages: {
en: `${siteUrl}/en/blog/${slug}`,
fr: `${siteUrl}/fr/blog/${slug}`,
es: `${siteUrl}/es/blog/${slug}`,
},
},
openGraph: {
type: "article",
title: post.title,
description: post.excerpt,
url: canonical,
publishedTime: post.date,
authors: ["Aïcha Imène DAHOUMANE"],
tags: post.tags,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}opengraph-image.tsx — Images OG générées de façon programmatique
Plutôt que des images statiques, générez des images Open Graph dynamiques avec l'API ImageResponse :
// app/[locale]/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
type Props = { params: Promise<{ slug: string; locale: string }> };
export default async function Image({ params }: Props) {
const { slug, locale } = await params;
const post = await getPostBySlug(slug, locale);
return new ImageResponse(
(
<div
style={{
background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
padding: "60px",
justifyContent: "space-between",
}}
>
<div style={{ color: "#22d3ee", fontSize: 18, fontWeight: 600 }}>
votresite.com
</div>
<div>
<div style={{ color: "#f1f5f9", fontSize: 52, fontWeight: 700, lineHeight: 1.2 }}>
{post?.title ?? "Article"}
</div>
<div style={{ color: "#94a3b8", fontSize: 24, marginTop: 20 }}>
{post?.excerpt}
</div>
</div>
</div>
),
{ ...size }
);
}Données structurées JSON-LD
Ajoutez des données structurées pour obtenir des rich results dans Google Search :
// components/JsonLd.tsx
export function PersonSchema() {
const schema = {
"@context": "https://schema.org",
"@type": "Person",
name: "Aïcha Imène DAHOUMANE",
url: "https://votresite.com",
jobTitle: "Développeuse Salesforce & Consultante IT",
sameAs: [
"https://github.com/Aiyeesha",
"https://www.linkedin.com/in/aïcha-imène-dahoumane",
],
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
export function ArticleSchema({ post, url }: { post: Post; url: string }) {
const schema = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
author: {
"@type": "Person",
name: "Aïcha Imène DAHOUMANE",
},
url,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}Utilisez-les respectivement dans votre layout et votre page :
// app/layout.tsx
import { PersonSchema } from "@/components/JsonLd";
// ...
<body>
<PersonSchema />
{children}
</body>Configuration next-sitemap
npm install next-sitemap// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://votresite.com",
generateRobotsTxt: true,
sitemapSize: 5000,
changefreq: "weekly",
priority: 0.7,
exclude: ["/admin/*", "/api/*"],
robotsTxtOptions: {
policies: [
{ userAgent: "*", allow: "/" },
{ userAgent: "*", disallow: ["/admin/", "/api/"] },
],
},
// Support multilingue
alternateRefs: [
{ href: "https://votresite.com/en", hreflang: "en" },
{ href: "https://votresite.com/fr", hreflang: "fr" },
{ href: "https://votresite.com/es", hreflang: "es" },
],
};// package.json
{
"scripts": {
"postbuild": "next-sitemap"
}
}URLs canoniques et hreflang
Définissez toujours des URLs canoniques pour éviter les pénalités de contenu dupliqué :
// Dans generateMetadata
alternates: {
canonical: `https://votresite.com/${locale}/blog/${slug}`,
languages: {
"en": `https://votresite.com/en/blog/${slug}`,
"fr": `https://votresite.com/fr/blog/${slug}`,
"es": `https://votresite.com/es/blog/${slug}`,
"x-default": `https://votresite.com/en/blog/${slug}`,
},
},Pièges courants
x-defaultmanquant : incluez toujours un hreflangx-defaultpointant vers votre langue principale- Images OG dupliquées : si vous avez un
opengraph-image.tsx, ne définissez pas aussiopenGraph.imagesdans les métadonnées — les deux entreraient en conflit - robots.txt qui bloque CSS/JS : certains
robots.txtgénérés bloquent accidentellement des assets — vérifiez survotresite.com/robots.txt - Routes dynamiques absentes du sitemap : assurez-vous que
generateStaticParamsest implémenté pour quenext-sitemapexplore bien tous les slugs