Next.js App Router replaces every SEO pattern from the Pages Router era (<Head>, getStaticProps, custom _document.js) with a unified Metadata API + file conventions. Here’s the complete setup.
Metadata API — per-page
Every page.tsx and layout.tsx can export metadata:
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About — Mahesh Waghmare',
description: 'WordPress + Astro developer running a 46-site digital empire solo.',
openGraph: {
title: 'About — Mahesh Waghmare',
description: 'WordPress + Astro developer running a 46-site digital empire solo.',
url: 'https://maheshwaghmare.com/about',
siteName: 'Mahesh Waghmare',
images: [{ url: '/og/about.png', width: 1200, height: 630 }],
type: 'profile',
},
twitter: {
card: 'summary_large_image',
title: 'About — Mahesh Waghmare',
description: '46 sites, solo, evenings.',
images: ['/og/about.png'],
},
};
export default function AboutPage() {
return <main>...</main>;
}
generateMetadata for dynamic routes
For app/blog/[slug]/page.tsx:
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
return {
title: `${post.title} — Mahesh Waghmare`,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
url: `https://maheshwaghmare.com/blog/${post.slug}`,
images: [`/og/blog/${post.slug}.png`],
type: 'article',
publishedTime: post.publishedAt,
authors: ['Mahesh Waghmare'],
},
};
}
Root layout metadata (site-wide defaults)
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://maheshwaghmare.com'),
title: {
template: '%s — Mahesh Waghmare',
default: 'Mahesh Waghmare',
},
description: 'WordPress + Astro developer running 46 sites solo.',
alternates: { canonical: '/' },
icons: { icon: '/favicon.svg' },
};
Setting title.template makes every per-page title automatically suffix with ” — Mahesh Waghmare”. metadataBase lets you write relative URLs everywhere else.
Sitemap — app/sitemap.ts
File-based — no next-sitemap library needed:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{
url: 'https://maheshwaghmare.com',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: 'https://maheshwaghmare.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
...posts.map((post) => ({
url: `https://maheshwaghmare.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.6,
})),
];
}
Next.js serves this at /sitemap.xml automatically.
Robots — app/robots.ts
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
{ userAgent: 'GPTBot', disallow: '/' }, // opt out of OpenAI training
],
sitemap: 'https://maheshwaghmare.com/sitemap.xml',
host: 'https://maheshwaghmare.com',
};
}
Dynamic OG images
Next.js bundles @vercel/og for runtime OG image generation:
// app/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';
export default async function OG({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return new ImageResponse(
(
<div style={{
background: 'linear-gradient(135deg, #f97316, #fbbf24)',
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '80px',
}}>
<div style={{ fontSize: 64, fontWeight: 700, color: '#fff' }}>
{post.title}
</div>
<div style={{ fontSize: 32, color: '#fff', opacity: 0.9, marginTop: 20 }}>
maheshwaghmare.com
</div>
</div>
),
{ ...size }
);
}
The image is generated on first request, cached at the edge, and served from /blog/<slug>/opengraph-image. Reference it in metadata:
export const metadata = {
openGraph: { images: ['/blog/my-post/opengraph-image'] },
};
Structured data (JSON-LD)
Inject <script type="application/ld+json"> per-page or in layout:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: 'Mahesh Waghmare',
url: 'https://maheshwaghmare.com/about',
},
image: `https://maheshwaghmare.com/og/blog/${post.slug}.png`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>...</article>
</>
);
}
Test with Google’s Rich Results tool after deploy.
Core Web Vitals
The other half of SEO. Next.js gives you tools but you have to use them:
Images — next/image
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero illustration"
width={1200}
height={630}
priority // LCP image — preload
/>
priority on the LCP image hints to the browser to preload — measurable difference on Largest Contentful Paint.
Fonts — next/font
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
Self-hosts fonts at build, eliminates FOIT, no CDN render-blocking.
Bundle analysis
(Requires @next/bundle-analyzer in next.config.js.)
Canonical URLs
Per-page:
export const metadata = {
alternates: { canonical: 'https://maheshwaghmare.com/blog/my-post' },
};
In app/layout.tsx with metadataBase, you can use relative paths:
export const metadata = {
metadataBase: new URL('https://maheshwaghmare.com'),
alternates: { canonical: '/' }, // resolves to https://maheshwaghmare.com/
};
i18n / multilingual
If you serve the same content in multiple languages:
export const metadata = {
alternates: {
canonical: '/blog/my-post',
languages: {
'en-US': '/en/blog/my-post',
'mr-IN': '/mr/blog/my-post',
},
},
};
Verification & monitoring
After deploy:
- Google Search Console — submit
sitemap.xml, verify indexation - Rich Results Test — validate your JSON-LD generates rich snippets
- PageSpeed Insights — Core Web Vitals scores per page
- Lighthouse CI — automate the audit in your pipeline
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Title appears twice in tab | Layout + Page both set non-template title | Use title.template in layout |
| OG image not appearing in shares | Cache — Twitter / FB cache OG images aggressively | Force re-scrape via FB Sharing Debugger |
| JSON-LD validates but no rich results | Schema mismatch, content gap, or just unindexed yet | Wait 7-14 days, then check Search Console “Enhancements” |
| Bundle too big | Too many 'use client' components | Audit with bundle analyzer, push state down |
| Search Console shows 404s | Sitemap includes URLs that 404 | Filter your sitemap.ts source data |
Conclusion
Next.js App Router SEO boils down to 5 files:
app/layout.tsx— root metadata defaultspage.tsx— per-pagemetadataorgenerateMetadataapp/sitemap.ts— your URL listapp/robots.ts— crawl rulesopengraph-image.tsx— dynamic social previews
Get these right + nail Core Web Vitals via next/image and next/font, and you’ve covered 90% of technical SEO without external tools.