Next.js App Router gives you powerful built-in tools for SEO. But most tutorials only scratch the surface. Here's a complete implementation covering metadata, Open Graph images, structured data, and sitemaps.
Prerequisites
- Next.js 14+ with App Router
- Basic understanding of metadata and SEO concepts
generateMetadata — Dynamic SEO Per Page
The generateMetadata function replaces the old Head component. It runs server-side and supports async data fetching:
// 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: "Post not found" };
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yoursite.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}`,
},
},
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 — Programmatic OG Images
Instead of static images, generate dynamic Open Graph images with the ImageResponse API:
// 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 }}>
yoursite.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 }
);
}JSON-LD Structured Data
Add structured data for rich results in Google Search:
// components/JsonLd.tsx
export function PersonSchema() {
const schema = {
"@context": "https://schema.org",
"@type": "Person",
name: "Aïcha Imène DAHOUMANE",
url: "https://yoursite.com",
jobTitle: "Salesforce Developer & IT Consultant",
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) }}
/>
);
}Use them in your layout and page respectively:
// app/layout.tsx
import { PersonSchema } from "@/components/JsonLd";
// ...
<body>
<PersonSchema />
{children}
</body>next-sitemap Configuration
npm install next-sitemap// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://yoursite.com",
generateRobotsTxt: true,
sitemapSize: 5000,
changefreq: "weekly",
priority: 0.7,
exclude: ["/admin/*", "/api/*"],
robotsTxtOptions: {
policies: [
{ userAgent: "*", allow: "/" },
{ userAgent: "*", disallow: ["/admin/", "/api/"] },
],
},
// Multilingual support
alternateRefs: [
{ href: "https://yoursite.com/en", hreflang: "en" },
{ href: "https://yoursite.com/fr", hreflang: "fr" },
],
};// package.json
{
"scripts": {
"postbuild": "next-sitemap"
}
}Canonical URLs and hreflang
Always set canonical URLs to prevent duplicate content penalties:
// In generateMetadata
alternates: {
canonical: `https://yoursite.com/${locale}/blog/${slug}`,
languages: {
"en": `https://yoursite.com/en/blog/${slug}`,
"fr": `https://yoursite.com/fr/blog/${slug}`,
"x-default": `https://yoursite.com/en/blog/${slug}`,
},
},Common Pitfalls
- Missing
x-default: Always includex-defaulthreflang pointing to your primary language - Duplicate OG images: If you have
opengraph-image.tsx, don't also setopenGraph.imagesin metadata — they'll conflict - robots.txt blocking CSS/JS: Some generated
robots.txtaccidentally block assets — verify atyoursite.com/robots.txt - Dynamic routes not in sitemap: Make sure
generateStaticParamsis implemented sonext-sitemapcrawls all slugs