Next.js SEO: what the framework does for you and what it does not

You ship with Next.js. You want pages that get crawled, indexed and clicked. Here is what the framework handles for SEO, what it exposes for you, and what no framework can do for you.

By , founder of Porteur · Updated 14 September 2026 · Markdown

What Next.js gives you for SEO by default

Next.js App Router ships with good SEO defaults. Pages are statically rendered by default. Dynamic rendering is opt in. Crawlers that do not run scripts can read your HTML.

  • Metadata API for titles, canonicals and Open Graph, via metadata objects or generateMetadata
  • app/sitemap.ts and app/robots.ts to output XML and robots.txt
  • Redirects and rewrites in next.config
  • next/image for responsive images and formats
  • next/font to self host fonts
  • A trailingSlash setting to enforce one URL style

These tools remove boilerplate work. They do not write your titles. They do not decide the canonical. They do not make thin pages rank.

Metadata API: titles, canonicals and social tags

Set per page metadata in the App Router. Use a static metadata export for simple pages. Use generateMetadata for dynamic values from params or data.

// app/pricing/page.tsx
export const metadata = {
  title: "Pricing for Acme",
  description: "Plans and limits.",
  alternates: { canonical: "/pricing" },
  openGraph: {
    title: "Pricing for Acme",
    description: "Plans and limits.",
    url: "/pricing",
    type: "website"
  }
};

// app/blog/[slug]/page.tsx
import { getPost } from "@/lib/data";
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  return {
    title: post.seoTitle ?? post.title,
    description: post.excerpt,
    metadataBase: new URL("https://yourproduct.com"),
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url: `/blog/${post.slug}`
    }
  };
}

Set metadataBase once so relative canonicals and Open Graph URLs resolve to the right origin. Use alternates.canonical for the page you want indexed. Keep titles under about 60 characters.

A fixed page looks like this: /guides/getting-started has a title that says the task, a canonical to itself, and an Open Graph image that matches the post.

Sitemap and robots: ship the right files from the app

Keep your sitemap small, current and on the same host. Output only indexable URLs. Exclude paginated filters and drafts. Update on deploys or when content changes.

// app/sitemap.ts
import { getAllRoutes } from "@/lib/routes";
export default async function sitemap() {
  const base = "https://yourproduct.com";
  const routes = await getAllRoutes(); // e.g. ["/","/pricing","/blog/nextjs-seo"]
  return routes.map((path) => ({
    url: `${base}${path}`,
    lastModified: new Date(),
    changeFrequency: "weekly",
    priority: 0.7
  }));
}
// app/robots.ts
export default function robots() {
  const base = "https://yourproduct.com";
  return {
    rules: [{ userAgent: "*" }],
    sitemap: [`${base}/sitemap.xml`],
    host: "yourproduct.com"
  };
}

Test the files in a browser. Check Search Console, Sitemaps, after deploy. If you split sitemaps, link a sitemap index at /sitemap.xml. Keep only canonical URLs in it.

Rendering: static HTML and crawlers that do not run scripts

Static rendering is the default in the App Router. That helps crawlers that fetch HTML only. Your title, canonical and content arrive in the first response.

  • Use fetch with cache options to keep a route static when data allows it
  • Mark truly dynamic routes with dynamic = "force-dynamic" only when needed
  • Avoid client components for content that could be server rendered
  • Do not hide primary content behind client side effects
// app/blog/[slug]/page.tsx
export const dynamic = "force-static"; // or omit if static by default
export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

If a route must be dynamic, keep the HTML meaningful without JavaScript. Render headings and copy on the server. Defer widgets to the client with care.

Redirects, rewrites and site moves

Use redirects for URL changes and migrations. Configure them in next.config so they run at the edge on most platforms. Keep hops to one.

// next.config.js
module.exports = {
  async redirects() {
    return [
      { source: "/old-pricing", destination: "/pricing", permanent: true },
      { source: "/blog/:slug/", destination: "/blog/:slug", permanent: true },
    ];
  },
  async rewrites() {
    return [
      { source: "/api/:path*", destination: "https://api.yourproduct.com/:path*" },
    ];
  },
};

Plan a site move like a project. Build a redirect map as a two column table in your repo. Old URL, new URL. Test every mapping before you switch DNS or deploy the change.

  1. Build the map

    Crawl the old site, export from its sitemap, and export indexed pages from Search Console. Merge into one list. Map each to one new URL.

  2. Apply and test

    Apply redirects in next.config or at your proxy. Script test that each old URL 301s in one hop to a 200 at the new URL.

  3. Switch and monitor

    Deploy. Keep the old domain responding with 301s. Submit the old sitemap for a while so Google recrawls and sees the moves.

For a domain change, verify both domains in Search Console. Use Settings, Change of address. It works for domain level moves. Not for path only changes. Expect some fluctuation for weeks. Keep redirects for at least a year, longer if you can. Update internal links, canonicals, hreflang, structured data and sitemaps to the new URLs.

URLs: trailing slashes, www and HTTPS

Pick one URL style and be consistent. Google treats /page and /page/ as different URLs. The root / is the exception. Next.js has a trailingSlash setting.

// next.config.js
module.exports = {
  trailingSlash: false, // or true, but choose one and redirect the other
};

Pick www or non www. Redirect the other, on every path, with a 301. Set canonicals to the chosen host. Verify a Domain property in Search Console so you see both.

Move to HTTPS if you are not already. 301 each HTTP URL to its HTTPS twin in one hop. Update internal links, canonicals and sitemaps. Avoid mixed content. Consider HSTS once you are sure.

A fixed page looks like this: /pricing loads at https://www.yourproduct.com/pricing, the non www and HTTP forms redirect to it, and its canonical matches the final URL.

Images and fonts: ship fast, stable pages

next/image helps with responsive sizes and modern formats. It can reduce payload and Largest Contentful Paint. You still choose sizes and placeholders that make sense for your layout.

// app/(marketing)/components/Hero.tsx
import Image from "next/image";
export default function Hero() {
  return (
    <Image
      src="/hero.png"
      alt="Dashboard overview"
      width={1200}
      height={800}
      priority
      sizes="(max-width: 768px) 100vw, 1200px"
      placeholder="blur"
      blurDataURL="/hero-blur.png"
    />
  );
}

The width and height shown here are examples, not measured on your page. Measure your hero and set exact dimensions for your layout.

Set explicit width and height to avoid layout shift. Use priority on the above the fold hero only. Lazy load the rest by default. Serve real alt text that says the image, not the keyword list.

next/font lets you self host fonts. That helps with privacy, control and performance. Preload the main font weight. Avoid layout shift by using font metrics or fallback settings.

// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export default function RootLayout({ children }) {
  return <html className={inter.className}><body>{children}</body></html>;
}

A fixed page looks like this: the hero uses next/image with correct sizes, the LCP element loads within 2.5 seconds, and there is no layout jump from fonts or images.

Measure and improve: Core Web Vitals and crawl checks

Check your field data. Core Web Vitals use the 75th percentile of real Chrome users over 28 days. Good thresholds are LCP up to 2.5 s, CLS up to 0.1, INP up to 200 ms.

  • Use PageSpeed Insights for field data and a Lighthouse run
  • Use Lighthouse locally for lab work. Scores vary by run
  • Watch the Search Console Core Web Vitals report for per URL groups
  • Use your platform logs or a crawler to find broken links and redirect chains

Lighthouse weights, as of version 10 and later, are TTB 30%, LCP 25%, CLS 25%, FCP 10% and Speed Index 10%. Use it to spot long tasks and render delays. Do not chase the score at the expense of features that users need.

  1. Check a slow page

    Open PageSpeed Insights for https://yourproduct.com/pricing. Note the LCP element and its load time. Note any layout shift sources.

  2. Fix what you ship

    Compress the hero image, serve the right size, and preload its font. Remove unused third party scripts. Keep INP under 200 ms by trimming expensive event handlers.

  3. Verify in the field

    Wait for the 28 day window to reflect changes. Confirm in Search Console, Core Web Vitals, that the URL group moved to good.

Porteur can scan your site and rivals from a URL and show three findings to start. It takes about thirty seconds and is free.

Questions

Sources

Check my site, free

Paste your URL to get a free check of your Next.js site’s metadata, sitemaps, redirects and rivals in about thirty seconds, with three findings you can ship.

  • Free check, no card
  • Read-only, your own accounts
  • Readable by your agent

Read next