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, images Open Graph, données structurées et sitemaps.
Prérequis
- Next.js 14+ avec App Router
- Notions de base en HTML/SEO
- Un projet déployé sur Vercel ou équivalent
generateMetadata — Au-delà des basiques
La fonction generateMetadata permet de générer des métadonnées dynamiques par page :
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPost(slug, locale);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://monsite.fr';
return {
title: `${post.title} | Mon Portfolio`,
description: post.excerpt,
alternates: {
canonical: `${siteUrl}/${locale}/blog/${slug}`,
languages: {
fr: `${siteUrl}/fr/blog/${slug}`,
en: `${siteUrl}/en/blog/${slug}`,
},
},
openGraph: {
title: post.title,
description: post.excerpt,
url: `${siteUrl}/${locale}/blog/${slug}`,
images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
type: 'article',
publishedTime: post.date,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}Image Open Graph générée automatiquement
Créez app/[locale]/blog/[slug]/opengraph-image.tsx pour générer une image OG dynamique :
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export default async function OGImage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px',
}}
>
<p style={{ color: '#22d3ee', fontSize: 24, margin: '0 0 16px' }}>
Mon Portfolio · Blog
</p>
<h1 style={{ color: 'white', fontSize: 56, lineHeight: 1.2, margin: 0 }}>
{post.title}
</h1>
</div>
),
size
);
}Sitemap dynamique
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
const posts = await getAllPosts();
const locales = ['en', 'fr'];
const blogUrls = posts.flatMap((post) =>
locales.map((locale) => ({
url: `${siteUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'monthly' as const,
priority: 0.7,
}))
);
const staticPages = locales.flatMap((locale) => [
{ url: `${siteUrl}/${locale}`, lastModified: new Date(), priority: 1.0 },
{ url: `${siteUrl}/${locale}/blog`, lastModified: new Date(), priority: 0.8 },
{ url: `${siteUrl}/${locale}/projects`, lastModified: new Date(), priority: 0.8 },
]);
return [...staticPages, ...blogUrls];
}Données structurées JSON-LD
Les données structurées aident Google à comprendre votre contenu et permettent d'obtenir des rich snippets :
// components/ArticleJsonLd.tsx
export function ArticleJsonLd({ post, url }: { post: Post; url: string }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updatedAt ?? post.date,
author: {
'@type': 'Person',
name: 'Aïcha Imène DAHOUMANE',
url: process.env.NEXT_PUBLIC_SITE_URL,
},
url,
image: post.coverImage,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}Robots.txt et meta robots
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/api/'],
},
],
sitemap: `${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml`,
};
}Pour les pages à ne pas indexer (admin, drafts) :
export const metadata: Metadata = {
robots: { index: false, follow: false },
};Pièges courants
- Métadonnées dupliquées : utilisez les URLs canoniques pour les pages avec paramètres similaires
- Balises title trop longues : restez entre 50 et 60 caractères
- Images OG manquantes : chaque article doit avoir une image 1200×630
- Hreflang mal configuré : vérifiez que chaque locale pointe vers l'autre via
alternates.languages - JSON-LD invalide : testez avec le Rich Results Test de Google
Récapitulatif
| Élément SEO | Implémentation Next.js |
|-------------|----------------------|
| Titre / Description | generateMetadata |
| Open Graph | openGraph dans metadata |
| Image OG | opengraph-image.tsx |
| URL canonique | alternates.canonical |
| Hreflang | alternates.languages |
| Sitemap | app/sitemap.ts |
| Données structurées | JSON-LD dans le layout |
| Robots | app/robots.ts |