EthicsComputerCourses
Join training +91 90532 10052

Metadata & SEO

Next.js · Lesson 18 of 27 Advanced 7 min read Updated

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>:

React JSX
// 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",
  },
};
React JSX
// 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:

React JSXTry it yourself
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>
);
Output
<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:

React JSX
// 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:

React JSX
// 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:

JavaScript
// 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:

JavaScript
// app/robots.js
export default function robots() {
  return {
    rules: { userAgent: "*", allow: "/", disallow: ["/admin/", "/api/"] },
    sitemap: "https://www.example-school.in/sitemap.xml",
  };
}
Text
User-Agent: *
Allow: /
Disallow: /admin/
Disallow: /api/

Sitemap: https://www.example-school.in/sitemap.xml

In 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:

React JSX
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 metadata from 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. Set metadataBase once in the root layout.
  • Exporting both metadata and generateMetadata from one file. Use one or the other.

Key points

  • Export metadata (static) or generateMetadata (data-driven) from layouts and pages; they merge from root to page.
  • title.template with %s builds consistent titles; metadataBase resolves relative URLs.
  • opengraph-image files or ImageResponse from next/og create share images.
  • app/sitemap.js and app/robots.js generate /sitemap.xml and /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
React JSX
// 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?

2. Where can metadata and generateMetadata be exported?

3. Which file creates /sitemap.xml from code?

4. Why replace < with < in JSON-LD?

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