Frontend11 min

Next.js SEO in 2026: metadata API, sitemaps, and Core Web Vitals

The Next.js SEO playbook for 2026 — generateMetadata, OpenGraph, JSON-LD, dynamic sitemaps, robots, image and font strategy, and Core Web Vitals targets engineers actually hit.

Google's ranking signals in 2026 still come down to the same three things they did two years ago: is your content useful, is your site fast, and can crawlers figure out what the page is about. What has changed is how much of the work Next.js does for you. The Metadata API, file-based sitemaps and robots, streaming metadata, and `next/font` remove almost every manual step that used to separate a mediocre SEO implementation from a good one. The catch: each of those pieces only helps if configured correctly, and the defaults stop short of what production SaaS actually needs. This is the playbook — what to use, what to skip, and the Core Web Vitals targets to hold the line on.

Metadata — static, dynamic, and streamed

The Metadata API is the right place to put every tag that used to live in `<head>`. Two patterns cover 95% of use cases. Static pages — marketing, pricing, legal — export a `metadata` constant. Dynamic pages — product, post, profile — export an async `generateMetadata` function that fetches whatever it needs and returns the object. Mixing the two on one page is an anti-pattern; pick based on whether the metadata depends on route params or data.

// 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" };

  const url = `https://vertexstack.io/blog/${slug}`;

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: url },
    openGraph: {
      type: "article",
      url,
      title: post.title,
      description: post.description,
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [
        {
          url: `https://vertexstack.io/og/${slug}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.description,
    },
    robots: { index: true, follow: true },
  };
}

// Root layout — title template + global defaults
export const metadata: Metadata = {
  metadataBase: new URL("https://vertexstack.io"),
  title: { default: "Vertex Stack", template: "%s — Vertex Stack" },
  description: "Production SaaS engineering, shipped.",
  openGraph: { siteName: "Vertex Stack", locale: "en_US", type: "website" },
};

Three details worth calling out. First, `metadataBase` in the root layout means relative image URLs resolve correctly everywhere. Without it, OG previews on Slack and X silently fall back to broken. Second, the title template cascades — children export a plain `title: "Pricing"` and the root layout wraps it. Third, `alternates.canonical` is the single most under-used field in the API; without it, query-string variants of the same page compete with each other in search.

Next.js 15.2 shipped streaming metadata, so an async `generateMetadata` no longer blocks initial paint for regular browsers. It still blocks for known HTML-limited bots (via the `htmlLimitedBots` mechanism), so crawlers always see metadata on first byte. You get fast UX and complete SEO without picking one.

Sitemaps and robots — the file-based way

Forget hand-writing `sitemap.xml`. Drop a `sitemap.ts` in the app root and export a function that returns the full URL set. Same with `robots.ts`. Both are revalidated like any other route, so the sitemap your CMS generates stays in sync with the content your app serves.

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

export const revalidate = 3600; // regenerate hourly

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const slugs = await getAllPostSlugs();
  const base = "https://vertexstack.io";
  const now = new Date();

  const staticRoutes: MetadataRoute.Sitemap = [
    { url: `${base}/`, lastModified: now, changeFrequency: "weekly", priority: 1 },
    { url: `${base}/pricing`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
    { url: `${base}/blog`, lastModified: now, changeFrequency: "daily", priority: 0.9 },
  ];

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

  return [...staticRoutes, ...posts];
}

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow: ["/api/", "/admin/", "/dashboard/"] },
    ],
    sitemap: "https://vertexstack.io/sitemap.xml",
    host: "https://vertexstack.io",
  };
}

For sites with more than 50,000 URLs (Google's per-file limit), return an array of sitemap functions and Next.js generates a sitemap index. Most SaaS sites won't hit that ceiling. What does matter: actual `lastModified` timestamps, not `new Date()` for every URL. Crawlers use that field to decide what to re-fetch, and lying about it wastes their budget and yours.

Structured data — the JSON-LD that earns rich results

Schema.org markup is the shortest path to rich results — FAQ snippets, article cards, product cards, breadcrumbs. In the App Router, render a `<script type="application/ld+json">` tag directly in the page or layout. Keep the object typed against `schema-dts` so it doesn't drift.

// app/blog/[slug]/page.tsx
import type { Article, WithContext } from "schema-dts";

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

  const jsonLd: WithContext<Article> = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { "@type": "Organization", name: "Vertex Stack" },
    publisher: {
      "@type": "Organization",
      name: "Vertex Stack",
      logo: { "@type": "ImageObject", url: "https://vertexstack.io/logo.png" },
    },
    mainEntityOfPage: `https://vertexstack.io/blog/${slug}`,
  };

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

Validate every schema you ship against Google's Rich Results Test before merging. Malformed JSON-LD doesn't break the page but silently disqualifies it from the richer SERP. Common mistakes: missing `datePublished`, `Article` without `author`, `Product` without `offers`, breadcrumbs that don't match the URL path.

Core Web Vitals — the 2026 numbers

Google still evaluates Core Web Vitals at the 75th percentile of real-user data over a 28-day window. Passing all three at the 75th is the line between a site that ranks and one that gets quietly deprioritized. INP is the one most sites still miss — roughly 43% of sites are failing the 200ms threshold in 2026 — and it's the one Next.js helps most with when you know where to look.

MetricGoodNeeds workPoorHow to hit it in Next.js
LCP (Largest Contentful Paint)≤ 2.5s2.5–4.0s> 4.0sStatic or ISR for above-the-fold content; `<Image priority>` on the hero; preload critical fonts; cache headers on the CDN.
INP (Interaction to Next Paint)≤ 200ms200–500ms> 500msPush rendering to RSC; keep client bundles small with `dynamic()`; defer non-critical effects; avoid synchronous layout thrash in handlers.
CLS (Cumulative Layout Shift)≤ 0.10.1–0.25> 0.25Always set `width`/`height` on `<Image>`; reserve space for ads and embeds; use `next/font` so fallback metrics are swapped cleanly.
TTFB (supporting metric)≤ 0.8s0.8–1.8s> 1.8sStatic export where possible; cache server actions; move slow DB calls out of the render path.

Images — the single biggest LCP lever

The `<Image>` component does the heavy lifting, but it only works if you use it correctly. `priority` on the LCP image, explicit `width` and `height` on every image (no more aspect-ratio drift), and `sizes` on responsive images so the browser downloads the right variant. AVIF and WebP are generated automatically; the fallback chain handles old browsers.

Fonts — where teams lose CLS and LCP

`next/font` self-hosts Google Fonts at build time, generates size-adjusted fallback metrics, and applies `font-display: swap` automatically. Configured correctly, it's zero CLS. Configured badly, it's the biggest invisible performance bug on the site.

// app/fonts.ts
import { Inter, JetBrains_Mono } from "next/font/google";

export const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
  preload: true,
});

export const mono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
  preload: false, // only preload the primary font
});

// app/layout.tsx
import { inter, mono } from "./fonts";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${mono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Three font mistakes we see constantly: (1) loading four weights when the design uses two, doubling font payload; (2) preloading every variant instead of just the primary, blowing up LCP; (3) importing fonts inside a client component so they re-download on navigation. Fonts belong in a server module imported by the root layout.

INP — reducing main-thread work

INP measures the longest delay between a user interaction and the next paint. The culprits are almost always the same: large React client bundles, heavy work inside event handlers, and third-party scripts. React Server Components fix the first class of problem at the architecture level. The other two need engineering discipline.

  • Default to Server Components. Mark a component `"use client"` only when it genuinely needs state, effects, or browser APIs. Every kilobyte of client JavaScript lives on the main thread.
  • Lazy-load heavy interactive islands with `next/dynamic({ ssr: false })` — chat widgets, rich editors, charts. First paint doesn't wait for them.
  • Wrap expensive state updates in `startTransition` so React can yield to user input. Pair with `useDeferredValue` for search-as-you-type patterns.
  • Load third-party scripts with `next/script` and the right strategy — `lazyOnload` for analytics, `afterInteractive` for consent banners, `worker` (experimental) for anything that supports Partytown.
  • Measure before optimizing. Ship `web-vitals` or Vercel Speed Insights to production and watch the real distribution — synthetic Lighthouse scores lie in both directions.

OG images — the /og route

Static OG images age badly. An `/og/[slug]` route that renders an image per post using `@vercel/og` or Next's built-in `ImageResponse` stays current automatically and turns social shares into consistent brand moments. Cache aggressively — these change once per publish at most.

// app/og/[slug]/route.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";

export const runtime = "edge";

export async function GET(req: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return new Response("Not found", { status: 404 });

  return new ImageResponse(
    (
      <div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0b0f19", color: "white", padding: 80 }}>
        <div style={{ fontSize: 28, opacity: 0.7 }}>Vertex Stack</div>
        <div style={{ fontSize: 64, fontWeight: 700, marginTop: 40 }}>{post.title}</div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      headers: { "Cache-Control": "public, immutable, no-transform, max-age=31536000" },
    }
  );
}

Internal linking, canonicals, and the boring fundamentals

The metadata API and Core Web Vitals are the visible work. The invisible work — internal links, canonical URLs, redirects from old paths, breadcrumbs matching URL structure — is what separates pages that rank from pages that just load fast.

  • Use `next/link` for every internal link. `<a href>` skips the router cache and kills navigation performance.
  • Define redirects for renamed routes in `next.config.ts` with `permanent: true` for 301s. Losing accumulated link equity to a 404 is a self-inflicted ranking drop.
  • Set `alternates.canonical` on every dynamic page. Parameterized URLs, UTM-tagged links, and query-based filters otherwise compete with each other.
  • Publish a real `hreflang` map if you serve multiple locales. Next.js middleware handles the routing; you still need the `alternates.languages` block in metadata.
  • Submit the sitemap to Search Console after every major content restructure — not just on first launch.

Key takeaways

  • `generateMetadata` owns everything that used to live in `<head>` — title, canonical, OG, Twitter, robots. A single root layout plus per-route overrides keeps it maintainable.
  • `sitemap.ts` and `robots.ts` are the only sitemap tooling you need. Make `lastModified` reflect reality, and use a sitemap index if you cross 50,000 URLs.
  • Ship JSON-LD via `<script type="application/ld+json">` and validate against Rich Results Test. FAQ, Article, Product, and BreadcrumbList cover most SaaS use cases.
  • Core Web Vitals targets in 2026: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1, all measured at the 75th percentile of real traffic. INP is where most sites still fail.
  • `next/font` and `<Image>` are non-negotiable. Used correctly, they eliminate the majority of CLS and LCP problems. Used badly, they're the problem.
#nextjs#seo#metadata#core-web-vitals#sitemap#structured-data#performance
Working on something similar?

Let's build it together.

We ship production SaaS, marketplaces, and web apps. If you want an engineering partner — not a consultancy — let's talk.