All templates
SEO · AEO · GEO~15 minCW-2

Meta tags for Next.js App Router — every page, the right defaults, properly inherited

Complete walkthrough of Next.js's Metadata API: root layout defaults, per-page overrides, dynamic routes with generateMetadata, OG image generation, validation tools.

Meta tags decide what shows up in Google search results, what appears when you paste your URL into LinkedIn or WhatsApp, and what AI engines see when they index your site. Next.js App Router gives you a clean Metadata API that handles all of this through TypeScript exports — no manual <head> tag writing. This guide is the complete pattern: root defaults, per-page overrides, dynamic routes, validation. Every line of code is paste-ready.

The Metadata API in one minute

In Next.js App Router, any page or layout file can export a `metadata` constant or an async `generateMetadata` function. Next.js reads these at build time (for static pages) or request time (for dynamic pages) and generates the right <head> tags.

The root layout's metadata acts as defaults. Per-page metadata overrides specific fields. The title template (`%s — Your Site`) merges seamlessly.

Step-by-step

  1. 1.Set up root layout metadata

    Open src/app/layout.tsx. Add a Metadata import and a metadata export.

    import type { Metadata } from "next";
    
    export const metadata: Metadata = {
      metadataBase: new URL("https://yourdomain.com"),
      title: {
        default: "Your Site — One-line tagline",
        template: "%s — Your Site",
      },
      description:
        "A 140-160 character sentence about what this site is, written so a stranger immediately knows whether to click. Specific beats generic.",
      applicationName: "Your Site",
      authors: [{ name: "Your Name", url: "https://yourdomain.com" }],
      generator: "Next.js",
      keywords: ["primary keyword", "secondary keyword", "your niche"],
      referrer: "origin-when-cross-origin",
      creator: "Your Name",
      publisher: "Your Site",
      openGraph: {
        type: "website",
        locale: "en_SG",
        url: "https://yourdomain.com",
        siteName: "Your Site",
        title: "Your Site",
        description: "Same or shorter than meta description.",
        images: [
          {
            url: "/og-default.png",
            width: 1200,
            height: 630,
            alt: "Your Site",
          },
        ],
      },
      twitter: {
        card: "summary_large_image",
        title: "Your Site",
        description: "Same or shorter than meta description.",
        images: ["/og-default.png"],
        creator: "@yourhandle",
      },
      robots: {
        index: true,
        follow: true,
        googleBot: {
          index: true,
          follow: true,
          "max-video-preview": -1,
          "max-image-preview": "large",
          "max-snippet": -1,
        },
      },
      icons: {
        icon: "/favicon.ico",
        shortcut: "/favicon-16x16.png",
        apple: "/apple-touch-icon.png",
      },
    };

    metadataBase is the linchpin

    Set metadataBase explicitly. It tells Next.js where to resolve relative URLs (like /og-default.png) to absolute ones. Without it, Open Graph image URLs may break in production.

  2. 2.Override per-page

    Each page exports its own metadata. The title template merges automatically.

    // src/app/about/page.tsx
    import type { Metadata } from "next";
    
    export const metadata: Metadata = {
      title: "About",
      description:
        "Who I am, what I do, why this site exists. 140-160 character specific summary.",
      openGraph: {
        title: "About — Your Site",
        description: "Short, specific.",
        type: "article",
      },
      alternates: {
        canonical: "/about",
      },
    };
    
    export default function AboutPage() {
      return <main>...</main>;
    }

    What gets inherited

    Per-page metadata replaces only the fields it specifies. Fields you don't specify inherit from the root layout. So if you set just title and description, OG image, locale, robots, etc. all come from the root defaults.

  3. 3.Dynamic routes use generateMetadata

    For routes like /blog/[slug], you need metadata that depends on which post is loaded. Use the async generateMetadata function.

    // src/app/blog/[slug]/page.tsx
    import type { Metadata } from "next";
    import { notFound } from "next/navigation";
    import { getPost } from "@/lib/posts";
    
    export async function generateMetadata({
      params,
    }: {
      params: Promise<{ slug: string }>;
    }): Promise<Metadata> {
      const { slug } = await params;
      const post = getPost(slug);
      if (!post) return { title: "Post not found" };
      return {
        title: post.title,
        description: post.description,
        openGraph: {
          title: post.title,
          description: post.description,
          type: "article",
          publishedTime: post.publishedAt,
          modifiedTime: post.updatedAt || post.publishedAt,
          authors: [post.author.name],
        },
        alternates: {
          canonical: `/blog/${slug}`,
        },
      };
    }
    
    export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
      const { slug } = await params;
      const post = getPost(slug);
      if (!post) notFound();
      return <article>...</article>;
    }

    Next.js 16: params is a Promise

    In Next.js 16, params is async — you must await it. In Next.js 14 and below, params was synchronous. If you copy older code, this trips you up.

  4. 4.Generate OG images on the fly

    Static /og-default.png works for v1. For per-page OG images, use Next.js's ImageResponse API to generate them dynamically. No design tool needed.

    // src/app/blog/[slug]/opengraph-image.tsx
    import { ImageResponse } from "next/og";
    import { getPost } from "@/lib/posts";
    
    export const runtime = "edge";
    export const size = { width: 1200, height: 630 };
    export const contentType = "image/png";
    
    export default async function OGImage({ params }: { params: { slug: string } }) {
      const post = getPost(params.slug);
      return new ImageResponse(
        (
          <div
            style={{
              background: "#FAF6F0",
              color: "#1A1612",
              width: "100%",
              height: "100%",
              display: "flex",
              flexDirection: "column",
              justifyContent: "center",
              padding: "80px",
            }}
          >
            <div style={{ fontSize: 60, fontWeight: 800, lineHeight: 1.1 }}>
              {post?.title || "Your Site"}
            </div>
            <div style={{ fontSize: 24, color: "#6B5D52", marginTop: 24 }}>
              yourdomain.com
            </div>
          </div>
        ),
        { ...size }
      );
    }

    What this does

    Next.js creates a 1200×630 PNG at /blog/[slug]/opengraph-image at build time. The OG image URL is automatically wired into the page's metadata. Per-page OG images, no Figma required.

  5. 5.Validate everywhere

    After deploy, paste your URL into these validators.

    • https://opengraph.dev/ — quick preview of OG tags across LinkedIn, Twitter, Facebook, Slack.
    • https://www.linkedin.com/post-inspector/ — LinkedIn-specific preview with cache refresh button.
    • https://cards-dev.twitter.com/validator (or your X equivalent) — Twitter card preview.
    • View source on each page; confirm <title>, <meta name='description'>, OG, Twitter, canonical all render.
    • Run Lighthouse SEO category — should be 100.

Common patterns to copy

Three full Metadata exports for the three most common page types.

  1. 1.Service page

    export const metadata: Metadata = {
      title: "Positioning sprint",
      description: "4-week positioning sprint for B2B SaaS founders past US$100k ARR. S$8,000 fixed scope.",
      openGraph: {
        title: "Positioning sprint — by Your Name",
        description: "Specific outcome, specific audience, specific price.",
        type: "article",
      },
      alternates: { canonical: "/services/positioning-sprint" },
    };
  2. 2.Contact page

    export const metadata: Metadata = {
      title: "Contact",
      description: "Reach Your Name by email, WhatsApp, or book a free 15-min intro call.",
      openGraph: {
        title: "Contact — Your Name",
        description: "Three channels: email, WhatsApp, calendar booking.",
        type: "website",
      },
      alternates: { canonical: "/contact" },
    };
  3. 3.Blog post (dynamic)

    export async function generateMetadata({ params }: Props): Promise<Metadata> {
      const { slug } = await params;
      const post = getPost(slug);
      if (!post) return { title: "Post not found" };
      return {
        title: post.title,
        description: post.excerpt,
        openGraph: {
          title: post.title,
          description: post.excerpt,
          type: "article",
          publishedTime: post.publishedAt,
          authors: ["Your Name"],
        },
        alternates: { canonical: `/blog/${slug}` },
      };
    }

Troubleshooting

OG image doesn't show when sharing on LinkedIn.

LinkedIn caches OG previews aggressively. Use the Post Inspector to force refresh. Also confirm: og:image URL is absolute (not relative), image is publicly accessible (not 404), image is at least 1200×630.

Title shows correctly in Google but wrong in social previews.

You probably set title but not openGraph.title — they're separate. Always set both, even if identical.

Lighthouse SEO score < 100 with the message 'Document does not have a meta description'.

A page is missing a description. Run npm run build and grep the build output for the page. Add description to that page's metadata.

Canonical URLs are wrong (pointing at localhost or vercel.app).

metadataBase isn't set or is set to the wrong URL. In root layout, set metadataBase: new URL('https://yourdomain.com').

Want to do this with us in the room?

Bring your real project to a full-day workshop and leave with it shipped.

See the workshops