~/TechPurAI
~/tutorials/nextjs-from-scratch/the-metadata-api
intermediate·part 9 of 22·3 min read

The Metadata API: static and per-page SEO

Updated Aug 16, 2026JavaScript · Next.js

Every page in this series so far has shared whatever <title> the browser defaults to — nothing has set one. Next.js's Metadata API generates <title>, <meta name="description">, Open Graph tags, and more, directly from exported objects and functions instead of hand-written <head> JSX.

Global defaults in the root layout

tsx
// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    default: "devnotes",
    template: "%s — devnotes",
  },
  description: "Notes on building with the App Router.",
};

An exported metadata object in any layout.tsx or page.tsx is how Next.js generates the actual <head> tags — nothing renders this by hand. title.template is what turns a page's own title into "Post Title — devnotes" automatically wherever %s appears — every page below only needs to provide its own piece, not the site name every single time.

A static override for one page

tsx
// app/posts/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "All posts",
  description: "Every post on devnotes, newest first.",
};

This page's <title> renders as "All posts — devnotes" — the root layout's template still applies, since only the page-specific piece is being overridden here, not the whole title strategy.

Dynamic metadata: generateMetadata

A static metadata export can't know which specific post is being viewed — [slug]/page.tsx needs a metadata function instead, one that reads the same route params the page component does.

tsx
// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import { getAllPosts } from "@/lib/posts";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = getAllPosts().find((p) => p.slug === slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.excerpt,
  };
}

generateMetadata runs before the page component itself, with the exact same params shape — same await params pattern from part 6. Each post now gets its own real <title> and description instead of every post sharing one generic tag, which is exactly what a search result snippet and a shared social link both actually read.

Open Graph and Twitter cards

tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = getAllPosts().find((p) => p.slug === slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.date,
    },
  };
}

openGraph controls how a link preview renders on platforms that read it — Slack, Twitter/X, LinkedIn. Without it, a shared link to a post typically falls back to whatever generic preview the site's root metadata provides, if anything at all.

Common mistake

Defining metadata as a plain static export on a dynamic route instead of using generateMetadata. A static metadata object has no access to params at all — it can't know which post is being viewed, so every post under [slug] would silently share the exact same title and description.

Next: structured data — the JSON-LD markup that goes beyond title and description, aimed specifically at how search engines and AI answer engines parse a page.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous8. Error handling: error.tsx and not-found.tsxnext →10. Structured data: JSON-LD for search and AI answer engines