Shipping SEO with Next.js isn’t about sprinkling meta tags; it’s about giving crawlers clean signals while keeping performance and DX high. This guide focuses on the App Router (Next.js 13+) and covers the must‑have building blocks, examples, and a blunt checklist you can run before every launch.
TL;DR
- Use the Metadata API (
metadata,generateMetadata) for canonical, OpenGraph, and Twitter cards. - Ship
robots.tsandsitemap.ts; verify they reflect your public vs private routes. - Generate dynamic OG images via
opengraph-image.tsxfor higher CTR. - Add structured data (JSON‑LD) for articles, breadcrumbs, products; validate it.
- Handle i18n/alternates with
metadata.alternates, not manual<link>tags. - Noindex low‑value pages (search, preview, dashboard) and fix canonicalization.
- Hit Core Web Vitals: image optimization, font loading, code‑splitting, caching.
1) Metadata API: Single Source of Truth
Define site‑wide defaults in app/metadata.ts and override per page or via generateMetadata.
// app/metadata.ts
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://www.averagedevs.com"),
title: {
default: "AverageDevs - Practical Engineering Guides",
template: "%s - AverageDevs",
},
description: "Actionable engineering guides for modern web apps.",
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
siteName: "AverageDevs",
url: "/",
images: [
{ url: "/opengraph-image.png", width: 1200, height: 630, alt: "AverageDevs" },
],
},
twitter: {
card: "summary_large_image",
creator: "@averagedevs",
},
};For dynamic pages (e.g., blog posts), compute metadata from content:
// app/blog/[slug]/page.tsx (excerpt)
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/site"; // example helper
export const generateMetadata = async ({ params }: { params: { slug: string } }): Promise<Metadata> => {
const post = await getPostBySlug(params.slug);
if (!post) return { title: "Not found", robots: { index: false, follow: false } };
return {
title: post.title,
description: post.description,
alternates: {
canonical: `/blog/${post.slug}`,
},
openGraph: {
type: "article",
url: `/blog/${post.slug}`,
title: post.title,
description: post.description,
images: [{ url: post.thumbnail, width: 1200, height: 630 }],
},
twitter: { card: "summary_large_image" },
};
};2) Robots and Sitemap You Actually Maintain
Keep these files in app/ so they are versioned, typed, and tested.
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/admin", "/api", "/preview"] },
],
sitemap: "https://www.averagedevs.com/sitemap.xml",
};
}// app/sitemap.ts
import type { MetadataRoute } from "next";
import { allPosts } from "@/lib/site"; // example helper
export default function sitemap(): MetadataRoute.Sitemap {
const base = "https://www.averagedevs.com";
const blogUrls = allPosts().map((p) => ({
url: `${base}/blog/${p.slug}`,
lastModified: p.updatedAt ?? p.date,
changeFrequency: "weekly",
priority: 0.7,
}));
return [
{ url: `${base}/`, changeFrequency: "weekly", priority: 0.8 },
...blogUrls,
];
}3) Dynamic Open Graph Images (Boost CTR)
Generate per‑page OG images with text/author overlays to improve social click‑throughs.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/site";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OgImage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) return new ImageResponse(<div>Not found</div>, { ...size });
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 64,
background: "#0b0b0b",
color: "white",
fontSize: 64,
}}
>
<div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.2 }}>{post.title}</div>
<div style={{ marginTop: 24, fontSize: 32, opacity: 0.7 }}>AverageDevs</div>
</div>,
{ ...size }
);
}4) Structured Data (JSON‑LD)
Use JSON‑LD for Articles, Breadcrumbs, Products, Events. Validate with Google’s Rich Results Test.
// app/blog/[slug]/page.tsx (excerpt)
const ArticleJsonLd = ({ post }: { post: any }) => (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.description,
image: [post.thumbnail],
datePublished: post.date,
dateModified: post.updatedAt ?? post.date,
author: [{ "@type": "Person", name: post.author || "AverageDevs" }],
}),
}}
/>
);Breadcrumbs example:
const BreadcrumbJsonLd = ({ slug, title }: { slug: string; title: string }) => (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Blog", item: "https://www.averagedevs.com/blog" },
{ "@type": "ListItem", position: 2, name: title, item: `https://www.averagedevs.com/blog/${slug}` },
],
}),
}}
/>
);5) Canonicalization and Noindex Rules
- Set
alternates.canonicalfor each canonical URL. - Paginated, filter, and search pages: prefer
noindex, follow. - Draft/preview/admin areas:
noindexand block inrobots.ts. - Avoid duplicate content from trailing slashes or query param variants.
// app/search/page.tsx
export const metadata = {
robots: { index: false, follow: true },
alternates: { canonical: "/search" },
} as const;6) i18n and Alternates (hreflang)
Use metadata.alternates.languages for localized routes.
// app/[locale]/layout.tsx (excerpt)
import type { Metadata } from "next";
export const metadata: Metadata = {
alternates: {
canonical: "/",
languages: {
"en-US": "/en",
"de-DE": "/de",
},
},
};7) Performance Signals That Matter for SEO
- Images: use
next/imagewith correctsizesand eager‑load only LCP. - Fonts: use
next/font; avoid layout shift; self‑host;display: swap. - Script strategy:
beforeInteractiveonly when essential; otherwiseafterInteractive. - Code splitting:
next/dynamicfor below‑the‑fold; SSR where beneficial. - Caching: leverage ISR/Route Segment Config; set long cache for static assets.
- Vitals: monitor LCP, CLS, INP; fix slow pages first - Google uses page‑level signals.
8) Analytics and Consent
Implement Consent Mode v2 if serving EEA/UK. Load analytics/ad scripts respecting consent and avoid polluting crawlable HTML with blocked widgets.
9) Pre‑Launch Checklist
- Home, key category pages, and every article have unique titles/descriptions.
- Canonical URLs set and correct; no accidental self‑canonical to wrong paths.
robots.tsallows public pages; blocks admin/preview.sitemap.tslists all public content and updateslastModifiedon change.- OpenGraph/Twitter cards render correctly and use final URLs.
- JSON‑LD validates for articles and breadcrumbs.
- Core Web Vitals green on mobile; images sized; fonts optimized.
- 404/500 pages exist and are fast; no broken links.
10) Common Pitfalls
- Mixing old Head API with Metadata API, causing duplicate tags.
- Forgetting
metadataBase, leading to relative OG URLs in social previews. - Returning stale
sitemapthat omits dynamic content or wronglastModified. - Rendering ads/embeds server‑side that bloat HTML and hurt LCP.
- No
noindexon search/filtered pages, causing index bloat and cannibalization.
Final Thoughts
Great SEO in Next.js is mostly about good defaults, consistent metadata, and fast pages. Ship the foundations once, automate where possible (sitemap/OG), and treat each new route as a small SEO contract: correct canonical, descriptive metadata, structured data if relevant, and no regressions to performance.
