LearnFrenzy
Sign in Take a free test

SEO and Metadata · Lesson 18 of 24

Next.js SEO: The Metadata API

8 min read Updated Nov 8, 2026 Saurabh Samir

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>;
}

<head> tags for /courses, then /

<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's siteName, locale and type are missing on /courses (compare them with the home page). Keep shared Open Graph fields in a constant and spread it into each page's openGraph.
  • Next.js filled in the Twitter title, description and image from the Open Graph values.
  • metadataBase turned /courses and /og/courses.png into 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>
  );
}

<head> tags for /courses/nextjs

<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>), alt text 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/image and next/font.
  • A sitemap.xml, a robots.txt and structured data (next lesson).

Practice

Try it yourself

  1. Give a /pricing page the title "Pricing" (through a "%s – Acme" template), a description and a canonical URL on https://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>;
    }

    <head> tags

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

Next lesson

Search LearnFrenzy

Esc

Quick links