Last updated: 2026-10-08

Metadata and SEO

Search engine optimization is not an afterthought in Next.js — it is built into the architecture. The same server-rendering model that makes pages fast to load also makes them fully readable by search engine crawlers, which need real HTML content to index a page effectively. But rendering HTML is only part of what search engines use to evaluate and rank a page. The metadata in the document head — the title, the description, the structured data, the Open Graph tags — is equally significant, and Next.js provides a dedicated, type-safe API for managing all of it.


Why Metadata Matters

When a search engine crawls a page, it reads two things: the content of the page itself and the metadata in the document head. The <title> tag determines what appears as the clickable headline in search results. The meta description — when present and well-written — appears beneath the headline and influences whether a user clicks. Open Graph tags determine how the page looks when shared on social platforms. Structured data, expressed in JSON-LD, provides additional context that search engines use for rich results — star ratings, event dates, product prices appearing directly in search output.

Getting these right, consistently, across every page in an application is what separates a site that is discoverable from one that is technically functional but invisible.


The metadata Export

Next.js provides a metadata export that can be placed in any layout.tsx or page.tsx file. It accepts a typed Metadata object, and Next.js uses it to generate the appropriate tags in the document head automatically.

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'WinehouseLabs',
  description: 'A software engineering research platform.',
};

This is the simplest possible metadata definition. Next.js renders it as:

<title>WinehouseLabs</title>
<meta name="description" content="A software engineering research platform." />

No <head> management, no document.title manipulation, no external library. The export is declared once, and Next.js handles the HTML.


The Title Template

When every page in an application needs its title to include the site name — Introduction to Node.js — WinehouseLabs, for example — repeating the site name in every page's metadata is redundant and error-prone. The title.template option in the root layout defines a pattern that is applied automatically to every page title beneath it.

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

export const metadata: Metadata = {
  title: {
    default: 'WinehouseLabs',       // used when no page sets a title
    template: '%s — WinehouseLabs', // applied to every page title
  },
  description: 'A software engineering research platform.',
};

With this in place, a page that sets its title to 'Introduction to Node.js' produces the document title 'Introduction to Node.js — WinehouseLabs'. A page that sets no title at all uses the default: 'WinehouseLabs'. The branding is consistent everywhere without being repeated anywhere.

// app/resources/essential-tools/node/introduction/page.tsx
export const metadata: Metadata = {
  title: 'Introduction to Node.js',
  // Produces: "Introduction to Node.js — WinehouseLabs"
};

Dynamic Metadata with generateMetadata

Static metadata works for pages whose titles and descriptions are fixed. For pages where the metadata depends on data — a blog post whose title comes from the database, a product page whose description is pulled from an API — the generateMetadata function handles it.

generateMetadata is an async function that receives the same params and searchParams as the page component and returns a Metadata object.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(
    r => r.json()
  );

  if (!post) {
    return { title: 'Post Not Found' };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
    },
  };
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(
    r => r.json()
  );

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

Next.js deduplicates the fetch — even though generateMetadata and the page component both call the same URL, the request is made once and the result is shared. The <title> and Open Graph tags in the document head reflect the specific post being viewed, not a generic page title.


Open Graph and Social Sharing

Open Graph is the protocol used by social platforms — Facebook, LinkedIn, Slack, WhatsApp — to generate link previews. When a URL is shared, the platform reads the page's Open Graph tags to determine the preview title, description, and image.

export const metadata: Metadata = {
  title: 'Introduction to Node.js',
  description: 'Understanding the runtime that powers the modern web.',
  openGraph: {
    title: 'Introduction to Node.js',
    description: 'Understanding the runtime that powers the modern web.',
    url: 'https://winehouselabs.com/resources/node/introduction',
    siteName: 'WinehouseLabs',
    images: [
      {
        url: 'https://winehouselabs.com/og/node-introduction.png',
        width: 1200,
        height: 630,
        alt: 'Introduction to Node.js — WinehouseLabs',
      },
    ],
    type: 'article',
  },
};

The standard Open Graph image size for most platforms is 1200×630 pixels. Providing an image is not required, but pages without one typically display a smaller, less visually engaging preview when shared — a meaningful difference for a platform that depends on sharing for visibility.

Twitter Card Tags

Twitter uses its own card system alongside Open Graph. Next.js handles both from the same metadata object:

export const metadata: Metadata = {
  twitter: {
    card: 'summary_large_image',
    title: 'Introduction to Node.js',
    description: 'Understanding the runtime that powers the modern web.',
    images: ['https://winehouselabs.com/og/node-introduction.png'],
  },
};

summary_large_image produces a card with a full-width image beneath the title and description — the most visually prominent card type and the one most commonly used for content-focused pages.


Generating Open Graph Images Dynamically

Next.js ships a built-in mechanism for generating Open Graph images programmatically — an opengraph-image.tsx file placed inside a route directory.

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

export const runtime = 'edge';

export default async function OGImage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(
    r => r.json()
  );

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          width: '100%',
          height: '100%',
          backgroundColor: '#1a1a1a',
          padding: '60px',
        }}
      >
        <p style={{ color: '#999', fontSize: 24 }}>WinehouseLabs</p>
        <h1 style={{ color: '#ffffff', fontSize: 60 }}>{post.title}</h1>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

ImageResponse renders JSX to a PNG image at the specified dimensions. Each blog post gets a unique, branded Open Graph image generated automatically from its title — without maintaining a library of manually created images. Next.js registers the generated image as the page's Open Graph image automatically; no additional metadata configuration is needed.


Robots and Sitemaps

Search engine crawlers follow instructions declared in a robots.txt file — which pages to crawl, which to avoid, where the sitemap lives. A sitemap lists every publicly indexable URL in the application, which helps search engines discover content efficiently.

Both can be generated programmatically in Next.js through special files in the app/ directory.

robots.ts

// app/robots.ts
import type { MetadataRoute } from 'next';

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

This generates a robots.txt file that allows crawlers to access all public routes, blocks the dashboard and API routes, and points to the sitemap. Next.js serves it automatically at /robots.txt.

sitemap.ts

// app/sitemap.ts
import type { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());

  const postUrls = posts.map((post: { slug: string; updatedAt: string }) => ({
    url: `https://winehouselabs.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }));

  return [
    {
      url: 'https://winehouselabs.com',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 1,
    },
    {
      url: 'https://winehouselabs.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
    ...postUrls,
  ];
}

Next.js generates a valid XML sitemap and serves it at /sitemap.xml. The sitemap is built at request time, ensuring newly published content appears without a rebuild.


Structured Data — JSON-LD

Structured data, expressed in the JSON-LD format, provides search engines with explicit, machine-readable information about a page's content. This is what enables rich results in Google Search — articles that display their publish date in search output, products that show their price and rating, events that show their location and time.

JSON-LD is embedded directly in the page component as a <script> tag with the application/ld+json type. Next.js does not have a dedicated metadata API for JSON-LD; it is added directly to the component:

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(
    r => r.json()
  );

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    author: {
      '@type': 'Organization',
      name: 'WinehouseLabs',
    },
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{post.title}</h1>
        <div>{post.content}</div>
      </article>
    </>
  );
}

Schema.org defines a vocabulary of types and properties — Article, Product, Event, Organization, BreadcrumbList — that search engines recognise. Applying the correct type to each page and populating its properties accurately is what determines eligibility for rich results.


Canonical URLs

When the same content is accessible at multiple URLs — with and without a trailing slash, through a redirect, via query parameters — search engines may index each URL independently, splitting the page's ranking signals. A canonical tag declares the authoritative URL for a piece of content, directing search engines to consolidate ranking signals on that single URL.

export const metadata: Metadata = {
  alternates: {
    canonical: 'https://winehouselabs.com/blog/introduction-to-nodejs',
  },
};

For dynamic routes, canonical URLs should be generated to match the exact URL structure of the page:

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  return {
    alternates: {
      canonical: `https://winehouselabs.com/blog/${slug}`,
    },
  };
}

A Complete Metadata Setup

A well-configured Next.js application combines all of these elements into a coherent SEO foundation:

// app/layout.tsx — root metadata applied to every page
export const metadata: Metadata = {
  metadataBase: new URL('https://winehouselabs.com'),
  title: {
    default: 'WinehouseLabs',
    template: '%s — WinehouseLabs',
  },
  description: 'A software engineering research platform.',
  openGraph: {
    siteName: 'WinehouseLabs',
    type: 'website',
    locale: 'en_GB',
  },
  twitter: {
    card: 'summary_large_image',
  },
  robots: {
    index: true,
    follow: true,
  },
};

metadataBase is the root URL of the application. When metadata fields contain relative URLs — Open Graph image paths, canonical URLs — Next.js resolves them against metadataBase automatically. Without it, relative URLs in metadata are unresolved and social platforms cannot retrieve the images they point to.


Summary

FeatureMethod
Static page title and descriptionexport const metadata: Metadata in layout.tsx or page.tsx
Consistent site-name brandingtitle.template in root layout
Dynamic metadata from dataexport async function generateMetadata
Open Graph tagsopenGraph key in the metadata object
Twitter card tagstwitter key in the metadata object
Programmatic OG imagesopengraph-image.tsx in the route directory
Robots.txtapp/robots.ts
Sitemapapp/sitemap.ts
Structured dataJSON-LD <script> tag in the page component
Canonical URLalternates.canonical in the metadata object
Base URL for relative pathsmetadataBase in root layout