6 min read
nextjs
seo
app-router
core-web-vitals
typescript

SEO with Next.js App Router

A practical guide to SEO in Next.js App Router, covering metadata, schema.org, sitemap and robots files, crawler behavior, and Core Web Vitals that actually matter in production.

generateMetadata()+ Server Componentserver-side SEO inputsRendered HTML + Metadatatitle, meta, OG tagsSearch Engine Crawlerindexing botSocial CrawlerOG tagsIndexed Result / Rich Link Previewcrawlread OG tags

SEO with Next.js App Router

I treat SEO as a product requirement, not a marketing checkbox.

In a Next.js App Router application, that means caring about what search engines actually receive, how metadata is generated per route, and whether performance decisions help or hurt crawling and ranking signals.

The App Router makes this easier because metadata, robots, sitemap generation, and Open Graph images are now first-class framework features rather than custom integrations.

Why App Router Changes SEO

The App Router changes the SEO conversation because more work happens inside Server Components.

Next.js automatically generates <head> tags through the Metadata API, and both metadata and generateMetadata are only supported in Server Components.

That is a significant improvement because SEO critical information can be resolved on the server instead of waiting for client-side hydration.

The other major change is streaming.

For dynamic routes, Next.js can stream metadata separately while rendering the page immediately.

For search engines, however, Next.js disables metadata streaming and sends metadata directly in the <head> where crawlers expect it.

That is exactly the tradeoff I want for SEO sensitive pages.

Metadata I Actually Use

For static routes, I usually export a simple metadata object.

For dynamic routes such as:

I use generateMetadata() so the title, description, canonical URL, and social metadata reflect the actual record being rendered.

Next.js also recommends sharing fetch logic between the page and metadata generation through React's cache() helper.

// app/products/[slug]/page.tsx
 
import type { Metadata } from "next";
import { cache } from "react";
 
const getProduct = cache(async (slug: string) => {
  const res = await fetch(`https://api.example.com/products/${slug}`, {
    next: { revalidate: 300 },
  });
 
  return res.json();
});
 
type Props = {
  params: Promise<{ slug: string }>;
};
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
 
  const product = await getProduct(slug);
 
  return {
    title: `${product.name} | My Store`,
    description: product.shortDescription,
    alternates: {
      canonical: `https://example.com/products/${slug}`,
    },
    openGraph: {
      title: product.name,
      description: product.shortDescription,
      url: `https://example.com/products/${slug}`,
      images: [
        {
          url: product.ogImage,
        },
      ],
    },
  };
}
 
export default async function ProductPage({ params }: Props) {
  const { slug } = await params;
 
  const product = await getProduct(slug);
 
  return <div>{product.name}</div>;
}

This pattern gives me dynamic metadata without duplicating requests or pushing SEO critical information into client-side code.

Title and Description Strategy

I prefer a simple convention:

Page Title | Site Name

Next.js supports title templates, which makes this easy to enforce across large applications.

The goal is consistency.

Users should immediately understand:

Descriptions matter too.

Even though meta descriptions are not a direct ranking factor, they influence click-through rate.

I write descriptions for humans, not search engines.

Good descriptions explain:

Structured Data

Whenever a page qualifies for rich results, I add schema.org structured data.

Common examples include:

I strongly prefer JSON-LD because it is easy to generate from the same data already powering the page.

function ProductStructuredData({
  name,
  description,
  image,
  url,
  price,
}: {
  name: string;
  description: string;
  image: string;
  url: string;
  price: string;
}) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name,
    description,
    image,
    url,
    offers: {
      "@type": "Offer",
      priceCurrency: "USD",
      price,
      availability: "https://schema.org/InStock",
    },
  };
 
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(jsonLd),
      }}
    />
  );
}

Keeping structured data close to page data reduces the risk of schema drift and keeps machine readable content aligned with what users actually see.

Sitemap and Robots

One of my favorite App Router improvements is the file-based approach to:

Instead of maintaining custom scripts, Next.js lets me generate them directly from the app directory.

robots.ts

// app/robots.ts
 
import type { MetadataRoute } from "next";
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/checkout/", "/account/"],
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}

sitemap.ts

// app/sitemap.ts
 
import type { MetadataRoute } from "next";
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const products = await fetch("https://api.example.com/products").then((r) =>
    r.json(),
  );
 
  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
    },
 
    ...products.map((product: { slug: string; updatedAt: string }) => ({
      url: `https://example.com/products/${product.slug}`,
      lastModified: new Date(product.updatedAt),
    })),
  ];
}

This keeps indexing infrastructure close to the application itself.

Crawlers and Streaming

Server Components are an SEO advantage because they allow meaningful HTML to be delivered immediately.

Streaming introduces an additional consideration.

Humans can benefit from progressively rendered content.

Crawlers generally prefer complete metadata up front.

Fortunately, Next.js already handles this distinction.

My rule remains simple:

If a page only becomes understandable after JavaScript runs, I consider that an SEO failure.

Core Web Vitals

Google currently focuses on three major metrics:

Largest Contentful Paint (LCP)

Target:

< 2.5 seconds

Interaction to Next Paint (INP)

Target:

< 200 milliseconds

Cumulative Layout Shift (CLS)

Target:

< 0.1

The App Router helps with these metrics, but architecture decisions still matter.

The most common issues I encounter are:

My first optimization is usually reducing client-side JavaScript.

If a component can stay server-rendered, I keep it server-rendered.

What I Optimize First

For ecommerce pages:

For blog content:

I also pay close attention to:

The framework helps, but poor asset choices can still destroy Core Web Vitals.

My SEO Checklist

Before shipping any App Router page, I verify:

Closing Takeaways

SEO in Next.js App Router is mostly about doing the fundamentals well.

I want:

When those pieces are in place, search engines, social platforms, and users all receive the same accurate representation of the page.

That consistency is what makes a site easier to crawl, easier to share, and easier to trust.

References

  1. Metadata and OG Images | Next.js Docs
  2. Adding Metadata | Next.js Learn
  3. Understanding Core Web Vitals and Google Search Results | Google Search Central
SEO with Next.js App Router | Enrique Ferreiro