SEO

SEO for Next.js Sites: Metadata, Sitemaps and Core Web Vitals

November 7, 2026 · 9 min read

SEO for Next.js Sites: Metadata, Sitemaps and Core Web Vitals

Next.js gives you most of what you need for SEO out of the box: a built-in Metadata API, file-based sitemaps and robots files, and tools for fast pages. But the defaults are not enough on their own. You still have to set titles, canonical URLs, social previews, structured data and performance correctly.

This guide walks through the setup for the App Router, with code you can copy. It is written for Next.js 16.

The Next.js SEO Checklist

  • Set site-wide metadata and metadataBase in the root layout.

  • Generate unique titles, descriptions and canonical URLs for dynamic pages.

  • Add a sitemap.ts and a robots.ts.

  • Add JSON-LD structured data to articles and products.

  • Pass Core Web Vitals with next/image, next/font and less client-side JavaScript.

  • Check everything in Google Search Console after launch.

1. Metadata

Site-wide defaults in the root layout

The Metadata API automatically creates the right <head> tags for each page. You can use a static metadata object or a generateMetadata function. Both only work in Server Components.

Start with defaults in your root layout:

tsx

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

export const metadata: Metadata = {
  metadataBase: new URL('https://yourdomain.com'),
  title: {
    default: 'Your Site Name',
    template: '%s | Your Site Name',
  },
  description: 'A clear one-sentence description of your site.',
  openGraph: {
    type: 'website',
    siteName: 'Your Site Name',
    images: ['/og-default.png'],
  },
  twitter: { card: 'summary_large_image' },
};

metadataBase lets you use relative paths in URL-based fields. Without it, using a relative path in one of those fields causes a build error. The title.template adds your site name to every child page title, and a default is required when you use a template.

One mistake to avoid: do not set alternates.canonical in the root layout. Child pages inherit it, and every page would point to the homepage as its canonical URL. Set canonicals per page instead.

Dynamic metadata for blog posts

For pages that depend on data, use generateMetadata:

tsx

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts'; // wrap in React cache()

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) notFound();

  return {
    title: post.metaTitle ?? post.title,
    description: post.metaDescription ?? post.excerpt,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.excerpt,
      publishedTime: post.publishedAt,
      images: [post.thumbnail],
    },
    twitter: { card: 'summary_large_image' },
  };
}

The ?? fallbacks mean an empty SEO field falls back to the post title or excerpt. If you fetch the same post in the page itself, wrap the fetch in React’s cache so it only runs once.

Open Graph images

For social previews, you can add a static opengraph-image.jpg file to a route folder. The more specific image wins over the one above it. For per-post images, add an opengraph-image.tsx file in blog/[slug] and use ImageResponse from next/og. A size of 1200 x 630 works well.

Metadata gotchas

  • Metadata objects from different segments are shallowly merged. If a page sets openGraph, it replaces the whole openGraph object from the layout, so add the fields you still need.

  • You cannot export both metadata and generateMetadata from the same segment.

  • File-based metadata, such as opengraph-image, takes priority over the object and function.

  • For dynamic pages, Next.js streams metadata after the first UI. It detects bots that cannot run JavaScript, such as facebookexternalhit, and blocks rendering for them so metadata is in the <head>. Googlebot runs JavaScript, and Next.js says it verified streamed metadata works for it.

  • With Cache Components on, if generateMetadata reads runtime data or uncached data while the page is otherwise static, Next.js raises an error. Either cache the data with 'use cache' or mark the page as intentionally dynamic.

2. Sitemaps and robots.txt

sitemap.ts

Create app/sitemap.ts and return an array of URLs:

ts

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://yourdomain.com';
  const posts = await getPublishedPosts(); // published posts only

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

Tips:

  • sitemap.ts is a special Route Handler and is cached by default, unless it uses request-time APIs or dynamic config.

  • Use real lastModified dates from your content, not new Date() on every build. Dates that always change are not useful to crawlers.

  • Include only URLs you want indexed: published, canonical and returning a 200 status.

  • Google’s limit is 50,000 URLs per sitemap. For bigger sites, use generateSitemaps, which creates files such as /product/sitemap/1.xml. In Next.js 16, the id argument is a promise.

  • Fields like changeFrequency and priority are supported, but Google has said it ignores them, so do not spend time tuning them.

  • You can also add images, videos and localized alternates to sitemap entries.

robots.ts

ts

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

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

Remember that robots.txt controls crawling, not indexing. To keep a page out of search results, use a noindex robots meta tag through the robots field in metadata, and do not block that page in robots.txt, or crawlers will never see the tag.

After deploying, submit your sitemap in Google Search Console and check the Pages report for errors.

3. Structured Data (JSON-LD)

JSON-LD helps search engines understand what a page is about. The Next.js recommendation is to render it as a <script> tag in your layout or page. For blog posts, use the BlogPosting type:

tsx

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  description: post.excerpt,
  image: post.thumbnail,
  datePublished: post.publishedAt,
  dateModified: post.updatedAt ?? post.publishedAt,
  author: { '@type': 'Person', name: 'Your Name' },
};

return (
  <article>
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
      }}
    />
    {/* page content */}
  </article>
);

The replace step matters. JSON.stringify does not protect against XSS in strings, so replacing < with its unicode form is the recommended fix. Use a normal <script> tag here, not next/script, because JSON-LD is data and not executable code. Validate your markup with Google’s Rich Results Test or the Schema Markup Validator. For type safety, you can use the community package schema-dts.

4. Core Web Vitals

The targets

Core Web Vitals measure three things. Pages pass when they meet the target at the 75th percentile of real page loads, for all three:

  • LCP (loading): 2.5 seconds or less

  • INP (responsiveness): 200 milliseconds or less

  • CLS (visual stability): 0.1 or less

A note on 2026: some blogs claim Google lowered the INP threshold to 150 ms. Google’s own documentation still lists 200 ms, and Google says stable Core Web Vitals do not change more than once a year. Use the official numbers, check web.dev for updates, and treat anything well under 200 ms as a safe goal.

Improve LCP

  • Preload the hero image. If one image is the LCP element, add preload to next/image. In Next.js 16, priority is deprecated in favor of preload.

tsx

<Image
  src="/hero.webp"
  alt="Describe the image for users and search engines"
  width={1200}
  height={630}
  sizes="100vw"
  preload
/>
  • If several images could be the LCP element depending on screen size, use loading="eager" or fetchPriority="high" instead.

  • Add the sizes prop to responsive images. Without it, the browser assumes the image is as wide as the viewport and may download an image that is too large.

  • Serve pages from static or cached output where you can, so the server responds fast.

  • Use next/font for fonts so they are self-hosted and do not block rendering.

Improve INP

  • Keep Client Components small. Use 'use client' only where you need state, effects or event handlers.

  • Load third-party scripts with next/script and a lazy strategy, and remove ones you do not need.

  • Check what you ship. Next.js 16.4 added a Turbopack Bundle Analyzer with a route summary, a sortable table and the ability to diff bundles over time.

  • Break up long tasks, and give users immediate visual feedback when they click.

Improve CLS

  • Always give images a width and height, or use fill inside a positioned parent. Next.js uses them to reserve space.

  • If you use the style prop for a custom width, also set height: 'auto'.

  • Reserve space for ads, embeds, banners and cookie notices.

  • Load fonts with next/font to avoid layout jumps when the font swaps in.

Measure the right way

  • Field data from real users is what counts. Use the Core Web Vitals report in Search Console, PageSpeed Insights and the Chrome User Experience Report.

  • Lighthouse cannot measure INP, because it does not interact with the page. Total Blocking Time is a useful lab proxy.

  • For your own monitoring, use the web-vitals library or the Next.js useReportWebVitals hook and send results to your analytics.

  • Lab and field numbers will differ. Fix field problems first.

5. Launch Checklist

  1. Every page has a unique title and description.

  2. metadataBase is set, and canonicals are set per page.

  3. Open Graph and Twitter images work. Test them by sharing a link.

  4. sitemap.xml and robots.txt load in the browser.

  5. JSON-LD passes the Rich Results Test.

  6. The LCP image uses preload, and all images have dimensions.

  7. Sitemap is submitted in Search Console.

  8. Core Web Vitals are green in the field report after a few weeks.

Frequently Asked Questions

Do I need a package like next-seo?

For the App Router, usually no. The built-in Metadata API, sitemap and robots files cover most needs.

Does Next.js create a sitemap automatically?

No. You add a sitemap.ts or sitemap.xml file yourself.

Can I use metadata in a Client Component?

No. metadata and generateMetadata only work in Server Components. Keep the page as a Server Component and move interactive parts into a separate Client Component.

Are Core Web Vitals a ranking factor?

They are one signal among many. Relevant, useful content still matters most, but slow or unstable pages hurt both users and search performance.

Does Google use changeFrequency and priority in sitemaps?

Google has said it ignores them. Accurate lastModified dates are more useful.

Conclusion

Good Next.js SEO is mostly careful setup: clear metadata on every page, an accurate sitemap, correct robots rules, structured data, and pages that load and respond quickly. Set these up once in your layout and shared helpers, then check Search Console regularly.

For details, read the official Next.js metadata docs and Google’s Web Vitals guide.

Want this built for you?

Our team offers Website Deploy On Vercel — get a free quote.

View Services