Skip to content
C Codeloom
Next.js

Next.js Metadata API for SEO

Master the Next.js Metadata API with static and dynamic titles, OpenGraph tags, sitemaps, and robots.txt to ship pages that rank and share well.

·6 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • Declaring static metadata with the metadata export
  • Generating per-route metadata with generateMetadata
  • Adding OpenGraph and Twitter card tags for rich previews
  • Composing titles with templates across nested layouts
  • Generating sitemap.xml and robots.txt programmatically

Prerequisites

  • A working App Router project: [Next.js App Router Basics](/blog/nextjs-app-router-basics)
  • Comfort with async server code: [Next.js Server Components](/blog/nextjs-server-components)

Search engines and social platforms read the <head> of your page to decide how to display, rank, and preview it. The Next.js Metadata API gives you a typed, declarative way to control every tag without manually editing the document head. It also covers the small files search engines expect at known locations: sitemap.xml and robots.txt.

Static metadata

The simplest form is a metadata export from a layout or page. Next.js renders the corresponding tags at build time or on each request, depending on how the route is resolved.

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

export const metadata: Metadata = {
  title: 'Codeloom - Practical engineering tutorials',
  description: 'Hands-on guides for Next.js, SQL, and modern web stacks.',
  keywords: ['nextjs', 'sql', 'tutorials'],
};

export default function HomePage() {
  return <main>Welcome</main>;
}

This produces a <title> tag, a <meta name="description"> tag, and so on. Anything in the Metadata type is type-checked, which catches typos and lets you discover supported fields without leaving your editor.

Dynamic metadata with generateMetadata

For routes that depend on data, export an async generateMetadata function. It receives the same params and searchParams as the page and can fetch whatever it needs.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
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 { title: 'Not found' };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `https://codeloom.dev/blog/${slug}` },
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{post?.title}</article>;
}

Next.js dedupes the data fetch, so calling getPost here and again in the page component does not double the database load if both use the same fetch cache or a memoized helper.

Title templates and inheritance

Most apps share a suffix like ” - Codeloom” across every page title. Define it once in the root layout using a template, and child routes only supply the unique part.

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

export const metadata: Metadata = {
  title: {
    template: '%s - Codeloom',
    default: 'Codeloom - Practical engineering tutorials',
  },
  description: 'Hands-on guides for Next.js, SQL, and modern web stacks.',
};

Now a child page that exports title: 'Server Actions' renders as Server Actions - Codeloom. The default is used when a child does not specify a title at all. Use title.absolute in a child if you need to bypass the template for that one page.

OpenGraph and Twitter cards

When a link is pasted into Slack, Discord, X, or LinkedIn, those platforms read OpenGraph tags to decide what to show. Configure them in the same metadata object.

// app/blog/[slug]/page.tsx
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,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url: `https://codeloom.dev/blog/${slug}`,
      siteName: 'Codeloom',
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [
        {
          url: `https://codeloom.dev/api/og?slug=${slug}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [`https://codeloom.dev/api/og?slug=${slug}`],
    },
  };
}

The /api/og route can use Next.js’s image response helper to generate a per-post preview image on demand. That is a cheap way to get attractive social shares without designing each image by hand.

Robots and indexing controls

Per-page index control is part of metadata. You can also generate a global robots.txt from a file in the app directory.

// app/draft/[id]/page.tsx
export const metadata = {
  robots: { index: false, follow: false },
};
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/draft/', '/api/'] },
    ],
    sitemap: 'https://codeloom.dev/sitemap.xml',
  };
}

This file becomes /robots.txt automatically. No template engine, no manual file.

Dynamic sitemaps

A sitemap is just an enumerated list of URLs. Generate it from your data source.

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { listPosts } from '@/lib/posts';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await listPosts();
  const base = 'https://codeloom.dev';

  return [
    { url: base, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
    ...posts.map((post) => ({
      url: `${base}/blog/${post.slug}`,
      lastModified: post.updatedAt,
      changeFrequency: 'monthly' as const,
      priority: 0.7,
    })),
  ];
}

For very large catalogs, split into multiple sitemaps by exporting generateSitemaps. The resulting files sit at /sitemap.xml and Next.js handles the index for you.

Icons and manifest

The Metadata API also recognizes files placed directly in the app directory. Drop a favicon.ico, an icon.png, an apple-icon.png, or an opengraph-image.tsx, and Next.js wires them up. The opengraph-image.tsx variant lets you write a React-rendered image that ships as a static PNG.

// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';

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

export default function OG() {
  return new ImageResponse(
    (
      <div style={{ fontSize: 64, background: '#0b0b0b', color: 'white', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        Codeloom
      </div>
    ),
    size,
  );
}

Common mistakes

  • Forgetting alternates.canonical on paginated or filtered pages, which dilutes ranking signals.
  • Returning different metadata than what the page actually shows. Search engines will downrank pages whose title and content disagree.
  • Hardcoding absolute URLs that drift between environments. Read your base URL from an environment variable and build URLs once.
  • Setting robots.index: false site-wide during local development and forgetting to remove it before deploying.

Wrap up

Treat metadata as a first-class output of every route. Use static metadata exports for stable pages, generateMetadata for anything that depends on data, title templates for consistency, and the file-based conventions for sitemaps, robots, and OG images. With these in place, your pages render cleanly in search results and look polished anywhere a link is shared.