Metadata & SEO
Search engines and social apps read the <head> of your pages: the title, the description, the preview image. Good metadata decides whether someone clicks your course page on Google or shares it on WhatsApp with a proper preview. Next.js has a Metadata API that writes these tags for you, plus special files for icons, share images, sitemaps and robots.txt.
In this lesson you will add static and generated metadata, use title templates, create Open Graph images, generate a sitemap and a robots file, and add structured data.
Static metadata
Export a metadata object from a layout or page (Server Components only). Next.js turns it into tags in <head>:
// app/layout.jsx
export const metadata = {
metadataBase: new URL("https://www.example-school.in"),
title: {
default: "Rohtak Code School",
template: "%s | Rohtak Code School",
},
description: "Coding courses in Rohtak for school and college students.",
openGraph: {
siteName: "Rohtak Code School",
locale: "en_IN",
type: "website",
},
};// app/courses/react/page.jsx
export const metadata = {
title: "React Basics",
description: "Learn React components, props and state in 6 weeks. Weekend batches in Rohtak.",
alternates: { canonical: "/courses/react" },
};Metadata is merged from the root layout down to the page. Here the page title React Basics is placed into the layout's template, giving React Basics | Rohtak Code School; pages without a title get the default. metadataBase turns relative URLs such as the canonical /courses/react into full URLs. Next.js always adds <meta charset="utf-8"> and a viewport tag, so you do not write those.
This runnable example performs the same merge in plain JavaScript and shows the tags and a search-result preview, so you can see what your choices produce:
import { createRoot } from "react-dom/client";
const layoutMeta = {
base: "https://www.example-school.in",
title: { default: "Rohtak Code School", template: "%s | Rohtak Code School" },
description: "Coding courses in Rohtak for school and college students.",
};
const pageMeta = {
title: "React Basics",
description: "Learn React components, props and state in 6 weeks. Weekend batches in Rohtak.",
canonical: "/courses/react",
};
function resolve(layout, page) {
const title = page.title ? layout.title.template.replace("%s", page.title) : layout.title.default;
return {
title,
description: page.description ?? layout.description,
canonical: page.canonical ? new URL(page.canonical, layout.base).href : null,
};
}
const m = resolve(layoutMeta, pageMeta);
const tags = [
`<title>${m.title}</title>`,
`<meta name="description" content="${m.description}">`,
`<link rel="canonical" href="${m.canonical}">`,
];
tags.forEach((t) => console.log(t));
createRoot(document.getElementById("root")).render(
<div style={{ fontFamily: "arial, sans-serif", maxWidth: 520 }}>
<div style={{ color: "#1a0dab", fontSize: 20 }}>{m.title}</div>
<div style={{ color: "#006621", fontSize: 14 }}>{m.canonical}</div>
<div style={{ color: "#545454", fontSize: 14 }}>{m.description}</div>
</div>
);<title>React Basics | Rohtak Code School</title> <meta name="description" content="Learn React components, props and state in 6 weeks. Weekend batches in Rohtak."> <link rel="canonical" href="https://www.example-school.in/courses/react">
Generated metadata for dynamic pages
When the title depends on data, such as one page per course, export an async generateMetadata function. It receives the same params Promise as the page:
// app/courses/[slug]/page.jsx
import { notFound } from "next/navigation";
import { getCourse } from "@/lib/courses"; // wrapped in React cache or "use cache"
export async function generateMetadata({ params }) {
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 },
};
}
export default async function CoursePage({ params }) {
const { slug } = await params;
const course = await getCourse(slug);
if (!course) notFound();
return <h1>{course.title}</h1>;
}Both functions call getCourse(slug). Wrap it in React's cache (or make it a "use cache" function) so the database is queried only once per request.
For pages rendered at request time, Next.js can stream metadata: the visible content starts arriving while generateMetadata finishes, and the tags are added when ready. Crawlers that need tags in the initial <head> (detected by their user agent) receive the complete head instead.
Open Graph images
When a link is shared on WhatsApp, LinkedIn or X, the app shows an image from the og:image tag. The easiest way is a file: put opengraph-image.png (or .jpg) in app/ for the whole site, or inside a route folder for that route. Next.js adds the tags, including width and height.
You can also generate the image with code. Create opengraph-image.jsx and return an ImageResponse from next/og, which turns JSX and inline styles into a PNG:
// app/courses/[slug]/opengraph-image.jsx
import { ImageResponse } from "next/og";
import { getCourse } from "@/lib/courses";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Course banner";
export default async function Image({ params }) {
const { slug } = await params;
const course = await getCourse(slug);
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column",
justifyContent: "center", padding: 80, background: "#111827", color: "white" }}>
<div style={{ fontSize: 72 }}>{course.title}</div>
<div style={{ fontSize: 36, marginTop: 24 }}>Rohtak Code School</div>
</div>
),
size
);
}ImageResponse supports flexbox and a subset of CSS; display: grid does not work. Like pages, params here is a Promise in Next.js 16.
sitemap.ts and robots.ts
A sitemap lists your URLs for search engines. Instead of writing XML by hand, export a function from app/sitemap.js (or .ts); Next.js serves the result at /sitemap.xml:
// app/sitemap.js
import { getCourses } from "@/lib/courses";
const base = "https://www.example-school.in";
export default async function sitemap() {
const courses = await getCourses();
return [
{ url: base, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
...courses.map((c) => ({
url: `${base}/courses/${c.slug}`,
lastModified: c.updatedAt,
changeFrequency: "monthly",
priority: 0.8,
})),
];
}app/robots.js produces /robots.txt in the same way:
// app/robots.js
export default function robots() {
return {
rules: { userAgent: "*", allow: "/", disallow: ["/admin/", "/api/"] },
sitemap: "https://www.example-school.in/sitemap.xml",
};
}User-Agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://www.example-school.in/sitemap.xmlIn TypeScript, type the return values as MetadataRoute.Sitemap and MetadataRoute.Robots (imported as import type { MetadataRoute } from "next"). A sitemap holds at most 50,000 URLs, so very large sites split it with generateSitemaps.
Structured data (JSON-LD)
Search engines also read structured data, a JSON description of what the page is about (a course, a product, an event). Render it as a script tag in the page. Replace < characters so text from your database cannot close the script tag early:
export default function CoursePage() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Course",
name: "React Basics",
description: "Components, props and state in 6 weeks.",
provider: { "@type": "Organization", name: "Rohtak Code School" },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<h1>React Basics</h1>
</>
);
}Common mistakes
- Exporting
metadatafrom a Client Component. It is ignored or causes an error. Keep metadata in Server Components (move interactive parts into child components). - Same title on every page. Use a template in the root layout and a unique title per page.
- Relative OG and canonical URLs without
metadataBase. SetmetadataBaseonce in the root layout. - Exporting both
metadataandgenerateMetadatafrom one file. Use one or the other.
Key points
- Export
metadata(static) orgenerateMetadata(data-driven) from layouts and pages; they merge from root to page. title.templatewith%sbuilds consistent titles;metadataBaseresolves relative URLs.opengraph-imagefiles orImageResponsefromnext/ogcreate share images.app/sitemap.jsandapp/robots.jsgenerate/sitemap.xmland/robots.txt.- Add JSON-LD with a script tag, escaping
<in the JSON.
Exercise
For a recipe site, write the root layout metadata with a title template %s | Ghar Ka Khana, then a generateMetadata for app/recipes/[slug]/page.jsx that sets the title, description and canonical URL from getRecipe(slug). Finally write app/robots.js that blocks /drafts/ and points to the sitemap.
Show solution
// app/layout.jsx
export const metadata = {
metadataBase: new URL("https://www.gharkakhana.example"),
title: { default: "Ghar Ka Khana", template: "%s | Ghar Ka Khana" },
description: "Simple home-style Indian recipes.",
};
// app/recipes/[slug]/page.jsx
import { getRecipe } from "@/lib/recipes";
export async function generateMetadata({ params }) {
const { slug } = await params;
const recipe = await getRecipe(slug);
if (!recipe) return { title: "Recipe not found" };
return {
title: recipe.name,
description: `${recipe.name}: ready in ${recipe.minutes} minutes. ${recipe.summary}`,
alternates: { canonical: `/recipes/${slug}` },
};
}
// app/robots.js
export default function robots() {
return {
rules: { userAgent: "*", allow: "/", disallow: "/drafts/" },
sitemap: "https://www.gharkakhana.example/sitemap.xml",
};
}The page title "Rajma Chawal" becomes "Rajma Chawal | Ghar Ka Khana" through the template, and the canonical path is made absolute by metadataBase.
Quick Quiz
1. The root layout has template: "%s | Pune Tutors" and a page sets title: "Maths". What is the final title?
The page title replaces %s in the nearest template.
2. Where can metadata and generateMetadata be exported?
The Metadata API is supported only in Server Components.
3. Which file creates /sitemap.xml from code?
app/sitemap.(js|ts) is a special file whose returned array becomes the XML sitemap.
4. Why replace < with < in JSON-LD?
Escaping < prevents injected text from closing the script element early.
Learn Next.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
