Metadata & SEO in Next.js đŸˇī¸

Introduction 👋

Good metadata is what makes a page look great in search results, social shares, and browser tabs. Next.js provides a dedicated Metadata API that handles titles, descriptions, Open Graph tags, icons, and more — both statically and dynamically. This tutorial covers everything needed to make your app discoverable and shareable.

Information

All metadata conventions covered here are part of the App Router's built-in Metadata API, with no extra libraries required.

What is Metadata? 🧠

Metadata is information about a page rather than its visible content — things like the page title, description, and preview image. It lives in the <head> of the HTML document and is read by browsers, search engines, and social platforms.

  • SEO metadata — helps search engines understand and rank a page.
  • Social metadata — controls how a link preview appears when shared.

Why Metadata Matters 📈

Well-crafted metadata directly impacts click-through rates from search results, how professional your links look when shared on social media, and how well search engines can categorize your content.

Tip

A missing or generic <title> is one of the most common — and easiest to fix — SEO mistakes.

Metadata API đŸ› ī¸

Next.js exposes two ways to define metadata: exporting a static metadata object, or exporting a dynamic generateMetadata function, both from a page.tsx or layout.tsx file.

app/page.tsx

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "My App",
  description: "The best app for managing your tasks.",
};

export default function Page() {
  return <h1>Welcome</h1>;
}

Static Metadata 🧊

Use the exported metadata object when a page's title and description are fixed and known ahead of time — this is the simplest and most common case.

app/about/page.tsx

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About Us",
  description: "Learn more about our mission and team.",
};

export default function About() {
  return <h1>About Us</h1>;
}

Dynamic Metadata 🔄

When metadata depends on runtime data — like a blog post's title fetched from a database — use generateMetadata instead of the static object.

app/blog/[slug]/page.tsx

import type { Metadata } from "next";

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPost(params.slug);
  return {
    title: post.title,
    description: post.excerpt,
  };
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return <h1>{post.title}</h1>;
}

generateMetadata đŸ§Ŧ

generateMetadata is an async function that receives the same params and searchParams as the page itself, and can fetch data to build a fully personalized metadata object.

Note

Next.js automatically deduplicates fetch calls shared between generateMetadata and the page component itself.

Metadata Merging & Inheritance đŸ§Ŧ🔗

Metadata exported from a layout.tsx is merged with metadata from the matching page.tsx, with the page's values taking precedence for any key defined in both. Most fields merge shallowly — an object like openGraph is replaced wholesale by the page's version rather than deep-merged field by field, so a page that sets its own openGraph must repeat any values it still wants from the layout.

app/layout.tsx

export const metadata = {
  title: "My App",
  openGraph: { siteName: "My App", images: ["/default-og.png"] },
};

app/blog/page.tsx

export const metadata = {
  // This REPLACES the layout's openGraph entirely — siteName is lost
  // unless it's repeated here.
  openGraph: { images: ["/blog-og.png"] },
};

Best Practice

When overriding a nested field like openGraph or twitter on a specific page, re-include any shared values (like siteName) rather than assuming they'll carry over.

Page Titles 📝

Titles can be set as a simple string, or as a template object that combines a parent layout's title pattern with each page's specific title.

app/layout.tsx

export const metadata = {
  title: {
    template: "%s | My App",
    default: "My App",
  },
};

app/dashboard/page.tsx

export const metadata = {
  title: "Dashboard", // renders as "Dashboard | My App"
};

Absolute Titles đŸŽ¯

Sometimes a page needs to opt out of a parent's title template entirely — a landing page or a co-branded page that shouldn't get the usual %s | My App suffix. The title.absolute field ignores any inherited template and renders exactly what you give it.

app/partner-landing/page.tsx

export const metadata = {
  title: {
    absolute: "Partner Co. × My App", // ignores the "%s | My App" template
  },
};

Viewport & Theme Color 📱

Since Next.js 14, viewport and themeColor are no longer part of the metadata object — they live in a separate viewport export (or a generateViewport function for dynamic values). Keeping them separate avoids blocking metadata resolution on values that don't affect SEO or social sharing.

app/layout.tsx

import type { Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  themeColor: "#000000",
};

Tip

If you're following an older tutorial that sets themeColor or viewport inside the metadata object, it'll trigger a build warning — move those fields into a separate viewport export instead.

Meta Descriptions 📄

The description field controls the short summary shown beneath a page's title in search results. Aim for a concise, compelling sentence under roughly 160 characters.

app/pricing/page.tsx

export const metadata = {
  description: "Simple, transparent pricing plans for teams of any size.",
};

Keywords 🔑

The keywords field lets you list relevant terms for a page, though modern search engines rely far more heavily on content quality and structure than on this field.

app/page.tsx

export const metadata = {
  keywords: ["task management", "productivity", "team collaboration"],
};

Note

Most major search engines no longer weight the keywords meta tag significantly — focus more on quality content and titles.

Authors, Publisher & Other Basic Fields âœī¸

A handful of smaller fields round out a page's basic metadata: authors and creator credit who made the content, publisher names the organization behind it, category classifies the content type, and formatDetection controls whether phone numbers, addresses, or emails get auto-linked by mobile browsers.

app/blog/[slug]/page.tsx

export const metadata = {
  authors: [{ name: "Jane Doe", url: "https://example.com/jane" }],
  creator: "Jane Doe",
  publisher: "My App Inc.",
  category: "technology",
  formatDetection: { telephone: false },
};

Site Verification 🔐

The verification field injects the meta tags that search consoles and webmaster tools use to confirm you own a site — Google Search Console, Bing Webmaster Tools, Yandex, and others each look for their own tag.

app/layout.tsx

export const metadata = {
  verification: {
    google: "google-site-verification-code",
    yandex: "yandex-verification-code",
    other: { me: ["my-email@example.com"] },
  },
};

Tip

Verification codes are usually only needed once, on the root app/layout.tsx — no need to repeat them on every page.

Canonical URLs 🔗

A canonical URL tells search engines which version of a page is the "official" one, preventing duplicate-content issues when the same content is reachable via multiple URLs.

app/products/[id]/page.tsx

export async function generateMetadata({ params }: { params: { id: string } }) {
  return {
    alternates: {
      canonical: `https://example.com/products/${params.id}`,
    },
  };
}

metadataBase 🧱

Fields like openGraph.images, twitter.images, and alternates.canonical often use relative paths for convenience. Next.js needs a base URL to resolve those into absolute URLs, which is exactly what metadataBase provides — set once on the root layout, it applies to every relative URL used anywhere in the app's metadata.

app/layout.tsx

import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  openGraph: {
    images: "/og.png", // resolves to https://example.com/og.png
  },
};

Best Practice

Without metadataBase, Next.js falls back to http://localhost:3000 during local development and warns in the console — always set it explicitly before deploying so social preview images resolve correctly in production.

Open Graph Metadata đŸ–ŧī¸

Open Graph tags control how a link appears when shared on platforms like Facebook, LinkedIn, and Slack — including the preview image, title, and description.

app/page.tsx

export const metadata = {
  openGraph: {
    title: "My App",
    description: "The best app for managing your tasks.",
    url: "https://example.com",
    siteName: "My App",
    images: [{ url: "https://example.com/og.png", width: 1200, height: 630 }],
    type: "website",
  },
};

Twitter Cards đŸĻ

The twitter metadata field customizes how a link appears specifically when shared on X, independent of the general Open Graph settings.

app/page.tsx

export const metadata = {
  twitter: {
    card: "summary_large_image",
    title: "My App",
    description: "The best app for managing your tasks.",
    images: ["https://example.com/twitter-card.png"],
  },
};

Dynamic Open Graph Images đŸŽŦ

Beyond a static opengraph-image.png, an opengraph-image.tsx file can generate a social preview image on the fly using JSX — perfect for embedding a blog post's title or a product's price directly into the share image.

app/blog/[slug]/opengraph-image.tsx

import { ImageResponse } from "next/og";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return new ImageResponse(
    (
      <div style={{ fontSize: 64, background: "#000", color: "#fff", width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
        {post.title}
      </div>
    ),
    { ...size }
  );
}

Tip

A file named exactly opengraph-image.tsx (or twitter-image.tsx for an X-specific variant) is picked up automatically — no manual wiring into the openGraph or twitter metadata fields required.

Icons 🎨

Next.js automatically detects icon files placed in the app directory — like icon.png or icon.tsx — and injects the appropriate <link> tags without manual configuration.

app
icon.png
apple-icon.png

Favicons 🌟

Placing a favicon.ico file directly in the app directory is the simplest way to set a site-wide favicon, automatically picked up with no extra configuration needed.

app
favicon.ico

App Icons 📱

An icon.tsx file can generate an icon dynamically at request time using JSX, letting you programmatically create favicons that reflect app state or branding variables.

app/icon.tsx

import { ImageResponse } from "next/og";

export const size = { width: 32, height: 32 };
export const contentType = "image/png";

export default function Icon() {
  return new ImageResponse(
    <div style={{ fontSize: 24, background: "black", color: "white" }}>A</div>,
    { ...size }
  );
}

Apple Touch Icons 🍎

An apple-icon.png file provides a dedicated, higher-resolution icon specifically used when a user adds your site to their iOS home screen.

app
apple-icon.png

Manifest File 📜

A manifest.ts (or static manifest.json) file describes your app for Progressive Web App installation, including its name, icons, and theme colors.

app/manifest.ts

import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "My App",
    short_name: "MyApp",
    icons: [{ src: "/icon.png", sizes: "192x192", type: "image/png" }],
    theme_color: "#000000",
    background_color: "#ffffff",
    display: "standalone",
  };
}

Static Manifest (manifest.json) 📋

If your manifest data never changes at runtime, you can skip the function entirely and drop a plain manifest.json file straight into the app directory. Next.js detects it automatically and serves it at /manifest.webmanifest, exactly like the generated version.

app/manifest.json

{
  "name": "My App",
  "short_name": "MyApp",
  "icons": [{ "src": "/icon.png", "sizes": "192x192", "type": "image/png" }],
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "display": "standalone"
}
app
manifest.json

Tip

Use the static file when the manifest is fixed content; reach for manifest.ts only when values need to be computed — for example, pulling a theme color from a CMS or environment variable.

Per-Page Robots Meta Tag đŸšĻ

Don't confuse this with the site-wide robots.txt file below — the metadata.robots field controls a per-page <meta name="robots"> tag, letting you keep an individual page out of the index (e.g. a thank-you page or an internal preview) without touching the crawl rules for the rest of the site.

app/preview/page.tsx

export const metadata = {
  robots: {
    index: false,
    follow: false,
    nocache: true,
    googleBot: {
      index: false,
      "max-video-preview": -1,
      "max-image-preview": "large",
    },
  },
};

Note

robots.txt tells crawlers which URLs to request in the first place; the per-page robots meta tag tells them whether to index a page they've already fetched. Both matter, and they solve different problems.

Robots.txt 🤖

A robots.ts file generates the robots.txt file that tells search engine crawlers which parts of your site they're allowed — or not allowed — to index.

app/robots.ts

import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/", disallow: "/admin" },
    sitemap: "https://example.com/sitemap.xml",
  };
}

Static Robots.txt 📄

Rules that never change can just be a plain robots.txt file placed in the app directory — no function needed. Next.js serves it as-is at the site root.

app/robots.txt

User-agent: *
Allow: /
Disallow: /admin

Sitemap: https://example.com/sitemap.xml
app
robots.txt

Tip

Prefer the static file for fixed rules; switch to robots.ts only when rules depend on environment (e.g. blocking all crawlers on a staging deployment).

Sitemap đŸ—ēī¸

A sitemap.ts file generates an XML sitemap listing all the important URLs on your site, helping search engines discover and crawl pages more efficiently.

app/sitemap.ts

import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: "https://example.com", lastModified: new Date() },
    { url: "https://example.com/about", lastModified: new Date() },
  ];
}

Static Sitemap (sitemap.xml) 🧭

For a small, unchanging set of URLs, a hand-written sitemap.xml file dropped into the app directory works just as well as the generated version, and avoids running any code at build or request time.

app/sitemap.xml

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com</loc>
  </url>
  <url>
    <loc>https://example.com/about</loc>
  </url>
</urlset>
app
sitemap.xml

Note

Static files are simplest for small, stable sites; once URLs number in the hundreds or come from a database, sitemap.ts (and its dynamic variant below) is the better fit.

Dynamic Sitemap 🔀

For sites with many pages — like a blog with hundreds of posts — sitemap.ts can fetch data and generate URLs dynamically, rather than listing every route by hand.

app/sitemap.ts

import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPosts();
  return posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
  }));
}

Sitemap Size Limits & Splitting 🧩

A single sitemap file is capped at 50,000 URLs by the sitemap protocol. For large sites that exceed this, Next.js supports generateSitemaps to split the URL set into multiple sitemap files, each served at its own indexed route and automatically linked together.

app/sitemap.ts

import type { MetadataRoute } from "next";

export async function generateSitemaps() {
  const totalPosts = await getPostCount();
  const pageCount = Math.ceil(totalPosts / 50000);
  return Array.from({ length: pageCount }, (_, i) => ({ id: i }));
}

export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
  const posts = await getPosts({ offset: id * 50000, limit: 50000 });
  return posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
  }));
}

Note

Each generated sitemap is served at a URL like /sitemap/0.xml, /sitemap/1.xml, and so on — most sites never need this, but it's essential once you're publishing at large scale.

Metadata Routes đŸˇī¸

Beyond sitemap.ts and robots.ts, Next.js supports several other special files — like opengraph-image.tsx and manifest.ts — collectively known as Metadata Routes, generated automatically at build or request time.

FileStatic AlternativeGenerates
sitemap.tssitemap.xmlsitemap.xml
robots.tsrobots.txtrobots.txt
manifest.tsmanifest.jsonmanifest.webmanifest
opengraph-image.tsxopengraph-image.pngsocial preview image
twitter-image.tsxtwitter-image.pngX/Twitter preview image
icon.tsxicon.pngfavicon

Tip

Every generated Metadata Route has a static file counterpart — drop in the plain file when the content is fixed, and only reach for the .ts/.tsx version when you need to compute the output at build or request time.

Structured Data (JSON-LD) 🧾

JSON-LD embeds machine-readable structured data directly in the page, helping search engines display rich results like star ratings, breadcrumbs, or product prices.

app/products/[id]/page.tsx

export default async function Product({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    offers: { "@type": "Offer", price: product.price },
  };

  return (
    <>
      <script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
      <h1>{product.name}</h1>
    </>
  );
}

Tip

Google's Rich Results Test is a great way to validate your JSON-LD structured data.

Alternate Languages 🌐

The alternates.languages field defines hreflang tags, telling search engines which URL to serve for users in different languages or regions.

app/page.tsx

export const metadata = {
  alternates: {
    languages: {
      "en-US": "https://example.com/en-US",
      "es-ES": "https://example.com/es-ES",
    },
  },
};

Social Sharing 📤

Combining Open Graph and Twitter Card metadata ensures a link looks polished and professional no matter which platform it's shared on.

Best Practice

Always provide an Open Graph image sized 1200×630 — it's the safest default across most social platforms.

SEO Best Practices ✅

  • Write unique, descriptive titles and descriptions for every page.
  • Use generateMetadata for pages whose content is fetched dynamically.
  • Keep your sitemap.ts (or static sitemap.xml) in sync with your actual published content.
  • Add structured data for content types like products, articles, or events where it applies.
  • Set metadataBase on the root layout so relative image URLs resolve correctly in production.

Performance & SEO ⚡

Search engines increasingly factor in Core Web Vitals — loading speed, interactivity, and visual stability — as ranking signals, making Next.js's rendering and streaming features directly beneficial to SEO.

Accessibility & SEO â™ŋ

Semantic HTML, meaningful alt text on images, and a logical heading hierarchy all improve both accessibility and how well search engines can parse and understand your content.

Common SEO Mistakes âš ī¸

  • Leaving every page with the same generic title and description.
  • Forgetting to update sitemap.ts / sitemap.xml as new content is added.
  • Missing Open Graph images, resulting in blank or broken social link previews.
  • Blocking important pages accidentally via an overly broad robots.ts / robots.txt rule.
  • Forgetting metadataBase, leaving relative OG/Twitter image URLs unresolved in production.
  • Confusing the per-page robots meta field with the site-wide robots.txt file — they solve different problems.

Frequently Asked Questions đŸ’Ŧ

Question

Can I mix static metadata and generateMetadata in the same app?

Answer

Yes — different route segments can each choose whichever approach fits their content.

Question

Does generateMetadata block the page from rendering?

Answer

Metadata resolves before the page is streamed, but Next.js optimizes this so it doesn't block visible content unnecessarily.

Question

Is a sitemap required for good SEO?

Answer

Not strictly required for small sites, but it's a best practice that helps search engines discover pages faster, especially on larger sites.

Question

Should I use robots.txt or robots.ts?

Answer

Use the plain robots.txt file when the rules are fixed. Reach for robots.ts only when the rules need to change based on environment or other runtime logic.

Question

Why do I need both a sitemap and a robots.txt?

Answer

robots.txt restricts what crawlers can request; the sitemap helps them find what they're allowed to request faster. They complement each other rather than overlap.

Summary 📌

Summary

Treat metadata as a first-class part of every page you build — it's often the deciding factor in whether someone clicks through from a search result or social feed.