SEO and Metadata · Lesson 18 of 24
Next.js SEO: The Metadata API
Search engines and social networks read a page's <head>: the title, the description, the canonical URL, Open Graph tags for link previews. In the App Router you never write these tags by hand – you export a metadata object (or a generateMetadata function) from a layout or page, and Next.js renders the correct tags, merging them down the route tree.
In this lesson
- Static metadata and title templates
metadataBase, canonical URLs and Open Graph- Dynamic metadata with
generateMetadata - An SEO checklist for Next.js pages
Site-wide defaults and a title template
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://www.learnfrenzy.com"), // turns relative URLs into absolute ones
title: {
default: "LearnFrenzy – Learn to Code for Free", // used when a page sets no title
template: "%s | LearnFrenzy", // pages fill in %s
},
description: "Free tutorials, interview questions and practice tests for developers.",
openGraph: { siteName: "LearnFrenzy", locale: "en_IN", type: "website" },
twitter: { card: "summary_large_image" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
// app/courses/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "All Courses",
description: "Browse free courses in React, Next.js, TypeScript, Python, Java and SQL.",
alternates: { canonical: "/courses" },
openGraph: { title: "All Courses", url: "/courses", images: ["/og/courses.png"] },
};
export default function Courses() {
return <h1>All courses</h1>;
}
// app/page.tsx
export default function Home() {
return <h1>Home</h1>;
}
<title>All Courses | LearnFrenzy</title> <meta name="description" content="Browse free courses in React, Next.js, TypeScript, Python, Java and SQL."> <link rel="canonical" href="https://www.learnfrenzy.com/courses"> <meta property="og:title" content="All Courses"> <meta property="og:description" content="Browse free courses in React, Next.js, TypeScript, Python, Java and SQL."> <meta property="og:url" content="https://www.learnfrenzy.com/courses"> <meta property="og:image" content="https://www.learnfrenzy.com/og/courses.png"> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="All Courses"> <meta name="twitter:description" content="Browse free courses in React, Next.js, TypeScript, Python, Java and SQL."> <meta name="twitter:image" content="https://www.learnfrenzy.com/og/courses.png"> <link rel="icon" href="/favicon.ico"> › open / <title>LearnFrenzy – Learn to Code for Free</title> <meta name="description" content="Free tutorials, interview questions and practice tests for developers."> <meta property="og:title" content="LearnFrenzy – Learn to Code for Free"> <meta property="og:description" content="Free tutorials, interview questions and practice tests for developers."> <meta property="og:site_name" content="LearnFrenzy"> <meta property="og:locale" content="en_IN"> <meta property="og:type" content="website"> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="LearnFrenzy – Learn to Code for Free"> <meta name="twitter:description" content="Free tutorials, interview questions and practice tests for developers."> <link rel="icon" href="/favicon.ico">
- The page's
title: "All Courses"went through the layout's template and became "All Courses | LearnFrenzy"; the home page, with no title, got the default. - Metadata merges field by field: the page's description replaced the layout's, and the layout's Twitter card setting was kept. But nested objects are replaced as a whole – because the page set its own
openGraph, the layout'ssiteName,localeandtypeare missing on /courses (compare them with the home page). Keep shared Open Graph fields in a constant and spread it into each page'sopenGraph. - Next.js filled in the Twitter title, description and image from the Open Graph values.
metadataBaseturned/coursesand/og/courses.pnginto absolute URLs, which canonical links and social cards require.
generateMetadata for dynamic pages
For pages built from data – a course, a product, a blog post – export an async generateMetadata. It receives the same params as the page:
// lib/courses.ts
import { cache } from "react";
const courses = {
react: { title: "React.js Tutorial", summary: "Learn React 19 from scratch: components, hooks, routing and testing." },
nextjs: { title: "Next.js Tutorial", summary: "Learn Next.js 16: App Router, Server Components, caching and SEO." },
} as const;
export const getCourse = cache(async (slug: string) => courses[slug as keyof typeof courses] ?? null); // once per request
export async function getSlugs() {
return Object.keys(courses);
}
// app/courses/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getCourse, getSlugs } from "@/lib/courses";
export async function generateStaticParams() {
return (await getSlugs()).map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps<"/courses/[slug]">): Promise<Metadata> {
const { slug } = await params;
const course = await getCourse(slug);
if (!course) return { title: "Course not found" };
return {
title: course.title,
description: course.summary,
alternates: { canonical: `/courses/${slug}` },
openGraph: { title: course.title, description: course.summary, url: `/courses/${slug}` },
};
}
export default async function CoursePage({ params }: PageProps<"/courses/[slug]">) {
const { slug } = await params;
const course = await getCourse(slug);
if (!course) notFound();
return <h1>{course.title}</h1>;
}
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://www.learnfrenzy.com"),
title: { default: "LearnFrenzy", template: "%s | LearnFrenzy" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
<title>Next.js Tutorial | LearnFrenzy</title> <meta name="description" content="Learn Next.js 16: App Router, Server Components, caching and SEO."> <link rel="canonical" href="https://www.learnfrenzy.com/courses/nextjs"> <meta property="og:title" content="Next.js Tutorial"> <meta property="og:description" content="Learn Next.js 16: App Router, Server Components, caching and SEO."> <meta property="og:url" content="https://www.learnfrenzy.com/courses/nextjs"> <meta name="twitter:card" content="summary"> <meta name="twitter:title" content="Next.js Tutorial"> <meta name="twitter:description" content="Learn Next.js 16: App Router, Server Components, caching and SEO."> <link rel="icon" href="/favicon.ico">
Because the page is prerendered with generateStaticParams, its metadata is resolved at build time and sits in the HTML <head>. For request-time pages, Next.js streams metadata in after the content to keep pages fast – except for search bots and link-preview bots, which always get it up front. Wrapping getCourse in React's cache means the metadata and the page share one data lookup.
An SEO checklist for Next.js
- A unique title (about 50–60 characters) and description (about 150) per page, with the page's main keyword.
- One
<h1>per page, and semantic HTML: headings in order, real links (<Link>),alttext on images. - A canonical URL on every indexable page, absolute via
metadataBase. - Open Graph and Twitter tags for good link previews, with a 1200×630 image (next lesson).
- Prerender what you can – static HTML is fast and fully crawlable – and keep Core Web Vitals healthy with
next/imageandnext/font. - A
sitemap.xml, arobots.txtand structured data (next lesson).
Practice
Try it yourself
Give a
/pricingpage the title "Pricing" (through a "%s – Acme" template), a description and a canonical URL onhttps://acme.dev.Show solution
// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { metadataBase: new URL("https://acme.dev"), title: { default: "Acme", template: "%s – Acme" }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ); }// app/pricing/page.tsx import type { Metadata } from "next"; export const metadata: Metadata = { title: "Pricing", description: "Simple plans for teams of every size. Start free, upgrade any time.", alternates: { canonical: "/pricing" }, }; export default function Pricing() { return <h1>Pricing</h1>; }<title>Pricing – Acme</title> <meta name="description" content="Simple plans for teams of every size. Start free, upgrade any time."> <link rel="canonical" href="https://acme.dev/pricing"> <link rel="icon" href="/favicon.ico">
Quick check
What does title.template do?
Wraps the titles of child pages, e.g. "%s | LearnFrenzy".
Why set metadataBase?
So relative URLs in canonical, Open Graph and image fields become absolute.
Which export creates metadata from route data?
generateMetadata, an async function receiving params.
Summary
- Export metadata from layouts and pages; Next.js renders and merges the head tags.
- Use a title template, metadataBase, canonical URLs and Open Graph defaults in the root layout.
- generateMetadata builds tags from data; share lookups with React cache.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.