Skip to content
Codeloom
Next.js

Next.js SEO: Meta Tags, Sitemap, and Structured Data

Optimize your Next.js app for search engines with the Metadata API, dynamic Open Graph images, sitemaps, robots.txt, and JSON-LD structured data.

·9 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • Using the Metadata API for static and dynamic meta tags
  • Generating sitemaps and robots.txt programmatically
  • Adding JSON-LD structured data to pages
  • Creating dynamic Open Graph images with next/og

Prerequisites

  • Next.js App Router basics
  • Basic understanding of HTML meta tags and SEO concepts

The Metadata API

Next.js provides a built-in Metadata API that handles all of your <head> tags. You export a metadata object or a generateMetadata function from any layout or page, and Next.js merges them automatically from the root down.

Static Metadata

For pages with fixed content, export a metadata object:

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

export const metadata: Metadata = {
  metadataBase: new URL("https://mysite.com"),
  title: {
    template: "%s | My Site",
    default: "My Site - Build Better Web Apps",
  },
  description:
    "Learn modern web development with practical tutorials and guides.",
  keywords: ["web development", "nextjs", "react", "tutorials"],
  authors: [{ name: "Your Name" }],
  creator: "Your Name",
  openGraph: {
    type: "website",
    locale: "en_US",
    siteName: "My Site",
  },
  twitter: {
    card: "summary_large_image",
    creator: "@yourhandle",
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      "max-video-preview": -1,
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
};

The title.template uses %s as a placeholder. When a child page sets title: "About", the rendered title becomes “About | My Site”.

Dynamic Metadata

For pages where metadata depends on data, use generateMetadata:

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

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,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author.name],
      images: [
        {
          url: post.coverImage || `/api/og?title=${encodeURIComponent(post.title)}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage || `/api/og?title=${encodeURIComponent(post.title)}`],
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

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

Next.js deduplicates the getPost call between generateMetadata and the page component, so you are not making two separate database queries.

Generating a Sitemap

A sitemap tells search engines about all the pages on your site. Create it programmatically:

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = "https://mysite.com";

  // Static pages
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: "monthly",
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 0.9,
    },
    {
      url: `${baseUrl}/contact`,
      lastModified: new Date(),
      changeFrequency: "yearly",
      priority: 0.5,
    },
  ];

  // Dynamic pages from database
  const posts = await db.post.findMany({
    where: { published: true },
    select: { slug: true, updatedAt: true },
    orderBy: { updatedAt: "desc" },
  });

  const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  return [...staticPages, ...postPages];
}

Next.js generates the XML sitemap at /sitemap.xml automatically.

Large Sitemaps with Multiple Files

If your site has thousands of pages, split the sitemap into multiple files:

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

export async function generateSitemaps() {
  const totalPosts = await db.post.count({ where: { published: true } });
  const perSitemap = 50000;
  const count = Math.ceil(totalPosts / perSitemap);

  return Array.from({ length: count }, (_, i) => ({ id: i }));
}

export default async function sitemap({
  id,
}: {
  id: number;
}): Promise<MetadataRoute.Sitemap> {
  const baseUrl = "https://mysite.com";
  const perSitemap = 50000;

  const posts = await db.post.findMany({
    where: { published: true },
    select: { slug: true, updatedAt: true },
    skip: id * perSitemap,
    take: perSitemap,
    orderBy: { createdAt: "asc" },
  });

  return posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
  }));
}

This generates /sitemap/0.xml, /sitemap/1.xml, etc., along with a sitemap index file.

Configuring robots.txt

Control which pages search engines can crawl:

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

export default function robots(): MetadataRoute.Robots {
  const baseUrl = "https://mysite.com";

  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/dashboard/", "/settings/"],
      },
      {
        userAgent: "GPTBot",
        disallow: "/",
      },
    ],
    sitemap: `${baseUrl}/sitemap.xml`,
  };
}

This produces a robots.txt that allows general crawling but blocks private sections and specific bots.

JSON-LD Structured Data

Structured data helps search engines understand your content and can earn rich results like star ratings, FAQ dropdowns, and breadcrumbs in search results.

Article Structured Data

// components/StructuredData.tsx
type ArticleProps = {
  title: string;
  description: string;
  author: string;
  publishedAt: string;
  modifiedAt: string;
  url: string;
  imageUrl: string;
};

export function ArticleJsonLd({
  title,
  description,
  author,
  publishedAt,
  modifiedAt,
  url,
  imageUrl,
}: ArticleProps) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: title,
    description: description,
    author: {
      "@type": "Person",
      name: author,
    },
    datePublished: publishedAt,
    dateModified: modifiedAt,
    image: imageUrl,
    url: url,
    publisher: {
      "@type": "Organization",
      name: "My Site",
      logo: {
        "@type": "ImageObject",
        url: "https://mysite.com/logo.png",
      },
    },
  };

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

Use it in your blog post page:

// app/blog/[slug]/page.tsx
import { ArticleJsonLd } from "@/components/StructuredData";

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return (
    <>
      <ArticleJsonLd
        title={post.title}
        description={post.excerpt}
        author={post.author.name}
        publishedAt={post.publishedAt}
        modifiedAt={post.updatedAt}
        url={`https://mysite.com/blog/${slug}`}
        imageUrl={post.coverImage}
      />
      <article>
        <h1>{post.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
    </>
  );
}

FAQ Structured Data

FAQ schema can get your content displayed as expandable answers in search results:

// components/FaqJsonLd.tsx
type FaqItem = {
  question: string;
  answer: string;
};

export function FaqJsonLd({ items }: { items: FaqItem[] }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    mainEntity: items.map((item) => ({
      "@type": "Question",
      name: item.question,
      acceptedAnswer: {
        "@type": "Answer",
        text: item.answer,
      },
    })),
  };

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

Breadcrumbs help search engines understand your site hierarchy:

// components/BreadcrumbJsonLd.tsx
type BreadcrumbItem = {
  name: string;
  url: string;
};

export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: items.map((item, index) => ({
      "@type": "ListItem",
      position: index + 1,
      name: item.name,
      item: item.url,
    })),
  };

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

Dynamic Open Graph Images

Generate social media preview images on-the-fly using next/og:

// app/api/og/route.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") || "My Site";
  const description = searchParams.get("description") || "";

  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "flex-start",
          justifyContent: "center",
          backgroundColor: "#0f172a",
          padding: "60px 80px",
        }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "center",
            marginBottom: "40px",
          }}
        >
          <div
            style={{
              width: "48px",
              height: "48px",
              borderRadius: "12px",
              backgroundColor: "#3b82f6",
              marginRight: "16px",
            }}
          />
          <span style={{ color: "#94a3b8", fontSize: "24px" }}>
            mysite.com
          </span>
        </div>
        <h1
          style={{
            fontSize: "64px",
            fontWeight: "bold",
            color: "#f8fafc",
            lineHeight: 1.2,
            marginBottom: "20px",
          }}
        >
          {title}
        </h1>
        {description && (
          <p
            style={{
              fontSize: "28px",
              color: "#94a3b8",
              lineHeight: 1.4,
            }}
          >
            {description}
          </p>
        )}
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  );
}

Reference this endpoint in your metadata:

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    openGraph: {
      images: [
        {
          url: `/api/og?title=${encodeURIComponent(post.title)}&description=${encodeURIComponent(post.excerpt)}`,
          width: 1200,
          height: 630,
        },
      ],
    },
  };
}

Canonical URLs and Pagination

Set canonical URLs to prevent duplicate content issues:

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

type Props = {
  searchParams: Promise<{ page?: string }>;
};

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  const { page } = await searchParams;
  const currentPage = parseInt(page || "1");

  return {
    title: currentPage > 1 ? `Blog - Page ${currentPage}` : "Blog",
    alternates: {
      canonical: currentPage === 1 ? "/blog" : `/blog?page=${currentPage}`,
    },
  };
}

Performance and Core Web Vitals

SEO is not just about meta tags. Google considers page performance through Core Web Vitals. Here are patterns that help:

// app/blog/[slug]/page.tsx
import Image from "next/image";

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article>
      {/* Priority loading for the hero image (LCP optimization) */}
      <Image
        src={post.coverImage}
        alt={post.title}
        width={1200}
        height={630}
        priority
        className="w-full rounded-lg"
      />

      <h1 className="text-4xl font-bold mt-8 mb-4">{post.title}</h1>

      {/* Preload critical fonts in your layout */}
      <div className="prose prose-lg max-w-none">
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </div>
    </article>
  );
}

Key performance tips for SEO:

  • Use priority on above-the-fold images to optimize Largest Contentful Paint.
  • Use next/image for automatic format conversion, sizing, and lazy loading.
  • Minimize client-side JavaScript with Server Components.
  • Use loading.tsx and Suspense boundaries for Cumulative Layout Shift prevention.

SEO Checklist Component

Here is a practical checklist you can reference when setting up SEO for any Next.js page:

// Example: complete SEO setup for a product page
// app/products/[id]/page.tsx

import type { Metadata } from "next";

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);

  return {
    title: product.name,
    description: product.description.slice(0, 160),
    alternates: {
      canonical: `/products/${id}`,
    },
    openGraph: {
      title: product.name,
      description: product.description.slice(0, 160),
      type: "website",
      images: [{ url: product.imageUrl, width: 1200, height: 630 }],
    },
    twitter: {
      card: "summary_large_image",
      title: product.name,
      description: product.description.slice(0, 160),
      images: [product.imageUrl],
    },
  };
}

Every page should have: a unique title under 60 characters, a description between 120 and 160 characters, a canonical URL, Open Graph tags, and Twitter Card tags. Structured data is a bonus that unlocks rich results.

Wrapping Up

Next.js gives you first-class tools for SEO through the Metadata API, file-based sitemap and robots.txt generation, and the next/og package for dynamic social images. Structured data with JSON-LD earns you rich search results, and Server Components naturally produce fast, crawlable pages.

The key is to set up your root layout with sensible defaults (template titles, global Open Graph settings, robots configuration) and then override specific metadata in each page with generateMetadata. Combined with a programmatic sitemap and structured data components, your Next.js app will be fully optimized for search engines from day one.