Skip to content
VerifiedUpdated 2026-06-13
On this page

SEO in the App Router is not a plugin and not an afterthought. It is a set of typed exports that Next.js turns into <head> tags and crawler files at build time. You write objects and functions, Next.js renders the markup. That means your metadata is type-checked, colocated with the route it describes, and impossible to forget on a new page once you wire the defaults.

You are going to build the whole stack on a real route: a blog with static and dynamic pages. By the end you will have a title template, canonical URLs, social cards, generated Open Graph images, a sitemap, a robots file, and JSON-LD structured data, all driven from one base URL.

How metadata resolves

Before the steps, hold one model in your head. Next.js evaluates metadata from the root layout down to the page, then shallowly merges each segment's object. A child title replaces the parent title. A child openGraph replaces the entire parent openGraph (nested objects do not deep-merge). Set your site-wide defaults in app/layout.tsx and override per route from there.

Set the base and defaults in the root layout

Everything URL-based (canonical, Open Graph images) needs an absolute URL. Instead of repeating your domain everywhere, set metadataBase once. Then define a title.template so every child page gets your brand suffix for free, plus a default title for routes that do not set their own.

app/layout.tsx
import type { Metadata } from "next";
 
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Example",
    template: "%s | Example",
  },
  description: "Secure by default Next.js work, notes, and field guides.",
  openGraph: {
    siteName: "Example",
    type: "website",
    locale: "en_US",
  },
  twitter: {
    card: "summary_large_image",
    creator: "@example",
  },
  robots: {
    index: true,
    follow: true,
  },
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

With metadataBase set, a relative openGraph.images: "/og.png" resolves to https://example.com/og.png. Without it, a relative URL is a build error, so this one line saves you later.

Add static metadata to a fixed page

For pages whose content does not depend on request data, export a plain metadata object. It is the cheapest path and fully prerendered. Here is a marketing-style page that overrides the title and sets its own canonical.

app/about/page.tsx
import type { Metadata } from "next";
 
export const metadata: Metadata = {
  title: "About",
  description: "Who runs Example and how the work gets shipped.",
  alternates: {
    canonical: "/about",
  },
};
 
export default function AboutPage() {
  return <main>About content</main>;
}

The rendered title is About | Example because the root template applies. The canonical resolves against metadataBase to https://example.com/about.

Set an explicit alternates.canonical on every indexable page. It is the single strongest signal against duplicate-content dilution from query strings, trailing slashes, and tracking parameters.

Generate metadata for a dynamic route

When the title and description depend on the URL, export an async generateMetadata instead of a static object. In Next.js 16 params is a Promise, so you must await it. You cannot export both metadata and generateMetadata from the same file; pick one per segment.

app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
 
type Props = {
  params: Promise<{ slug: string }>;
};
 
export async function generateMetadata({
  params,
}: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
 
  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `/blog/${slug}`,
    },
    openGraph: {
      type: "article",
      title: post.title,
      description: post.excerpt,
      url: `/blog/${slug}`,
      publishedTime: post.publishedAt,
      authors: [post.author],
    },
  };
}
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
    </article>
  );
}

Notice that both generateMetadata and the page call getPost(slug). That is two calls for one render. Deduplicate by wrapping the data function in React's cache so the work runs once per request.

lib/posts.ts
import { cache } from "react";
 
export type Post = {
  slug: string;
  title: string;
  excerpt: string;
  author: string;
  publishedAt: string;
  updatedAt: string;
};
 
export const getPost = cache(async (slug: string): Promise<Post | null> => {
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  if (!res.ok) return null;
  return res.json();
});

Add Open Graph and a generated OG image

You already set Open Graph fields in the steps above. The missing piece is the image. A static opengraph-image.png in the app/ root covers your whole site. For per-post images that show the post title, add a dynamic opengraph-image.tsx next to the page and render it with ImageResponse from next/og.

app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";
 
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
 
  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: 80,
          background: "#0a0a0a",
          color: "#fafafa",
          fontSize: 64,
        }}
      >
        <div style={{ fontSize: 28, opacity: 0.6 }}>example.com</div>
        <div style={{ marginTop: 24 }}>{post?.title ?? "Example"}</div>
      </div>
    ),
    { ...size }
  );
}

Because this file sits in the same folder as the post, Next.js wires og:image and twitter:image to it automatically. You do not list it in the openGraph.images array; the file convention has higher priority and overrides config. ImageResponse supports flexbox and a subset of CSS only. display: grid will not render, so stick to flex layouts.

Avoid

app/blog/[slug]/opengraph-image.tsx
// params is a Promise in Next.js 16; reading .slug
// directly is undefined and the card shows the fallback.
export default async function Image({ params }) {
  const post = await getPost(params.slug);
  // ...
}

Prefer

app/blog/[slug]/opengraph-image.tsx
export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  // ...
}

Generate sitemap.ts and robots.ts

Crawlers need a map and a rulebook. Both are typed file conventions in the app/ root. Export a default function returning MetadataRoute.Sitemap for the map and MetadataRoute.Robots for the rules.

app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";
 
const BASE_URL = "https://example.com";
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();
 
  const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${BASE_URL}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly",
    priority: 0.7,
  }));
 
  return [
    { url: BASE_URL, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
    { url: `${BASE_URL}/about`, changeFrequency: "monthly", priority: 0.5 },
    ...postEntries,
  ];
}
app/robots.ts
import type { MetadataRoute } from "next";
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/api/", "/admin/"],
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}

These serve at /sitemap.xml and /robots.txt. Both are cached by default and prerendered at build time. They only become dynamic if you read a request-time API (cookies(), headers()) or set a dynamic config option inside them, so a fetch for your post list keeps them static and fast.

Do not list your admin, account, or internal API paths in disallow and assume they are protected. robots.txt is a public file and a polite request, not access control. It tells honest crawlers to skip a path and tells everyone else exactly where to look. Gate sensitive routes with real authorization.

Add JSON-LD structured data

Meta tags describe the page; JSON-LD describes the thing on the page in a vocabulary Google reads for rich results. There is no metadata field for it. You render a <script type="application/ld+json"> directly in the component, server-side, with the payload stringified.

app/blog/[slug]/page.tsx
import { getPost } from "@/lib/posts";
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return null;
 
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { "@type": "Person", name: post.author },
    url: `https://example.com/blog/${slug}`,
  };
 
  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
    </article>
  );
}

You build the object from typed data, so the shape is yours and never user-controlled HTML. JSON.stringify escapes the values, and because this is a Server Component the script ships in the initial HTML where crawlers see it without running JavaScript.

Google Search Console

Metadata, a sitemap, and robots make your site indexable. Google Search Console is how you confirm Google actually indexed it, see which queries you rank for, and catch crawl errors before they cost you traffic. It is also a prerequisite for AdSense: a site Google has not crawled and indexed is not a site AdSense will approve. Verify the property once, submit the sitemap, then check it after launch.

Whenyou set up a new site and want Google to track its indexing

Doadd the property in Search Console and verify ownership. The DNS TXT method verifies the whole domain at once; the HTML meta tag verifies a single origin and is one line in your root metadata.

app/layout.tsx
export const metadata: Metadata = {
  // The token from Search Console's "HTML tag" verification method.
  verification: { google: "your-search-console-token" },
};
// Renders: <meta name="google-site-verification" content="..." />

Whenyour property is verified

Dosubmit your sitemap URL in Search Console (Sitemaps -> add /sitemap.xml). Do not wait for Google to discover it; submitting tells Google your full URL set immediately.

// You already generate it (see the sitemap.ts step above).
// In Search Console: Sitemaps -> https://yoursite.com/sitemap.xml

Whena page must never appear in search (auth shells, dashboards, thank-you pages)

Doset robots index:false on the page itself, and keep it out of the sitemap and the robots allow-list. Crawl budget should go to content, not login forms.

app/(auth)/login/page.tsx
export const metadata: Metadata = {
  robots: { index: false, follow: false },
};

Whenyou write robots.ts with both an allow and a disallow on overlapping paths

Doremember Google applies the longest matching rule. Disallow: /library/ will silently drop an allowed /library listing. Block specific private subpaths, not a broad prefix that also catches public pages.

app/robots.ts
// Block the gated subpaths individually so the public listing stays crawlable.
disallow: ["/library/[id]-private-only?", "/dashboard/", "/api/", "/auth/"],
// NOT: disallow: ["/library/"]  // longest-match would drop /library too

Whenafter launch, or after a content or structure change

Docheck Search Console for coverage errors and use URL Inspection to request indexing on important new pages. Treat 'Discovered but not indexed' as a signal the page is too thin or too similar to others.

// No code: Search Console -> URL Inspection -> paste the URL ->
// Request Indexing. Then watch Coverage for crawl/index errors.

A note on streaming metadata

For dynamically rendered pages, Next.js streams metadata in after the initial UI to improve perceived performance, then appends the resolved tags once generateMetadata settles. It disables streaming for HTML-limited bots (detected by User Agent) that expect tags in <head>, so link previews on Slack and Facebook stay correct without any extra work from you. Prerendered pages skip streaming entirely because their metadata is already resolved at build time. The default behavior is right for nearly everyone; reach for the htmlLimitedBots config only if you have a specific crawler problem to solve.

Sources

  • Next.js docs01-app/01-getting-started/14-metadata-and-og-images.md
  • Next.js docs01-app/03-api-reference/04-functions/generate-metadata.md
  • Next.js docs01-app/03-api-reference/03-file-conventions/01-metadata/sitemap.md
  • Next.js docs01-app/03-api-reference/03-file-conventions/01-metadata/robots.md