Skip to content
~sami-ullah

Published Updated on 6 min read

Next.js App Router SEO Checklist for Next.js 15

Next.js App Router SEO comes down to a short list: set metadataBase and a title template in the root layout, give every route a unique title, description and canonical, ship sitemap.ts and robots.ts, add JSON-LD, keep important pages static, size images properly and return real 404s. This checklist shows the Next.js 15 code for each item.

By · #nextjs #seo #performance

The Next.js App Router gives you most of what search engines need out of the box: server-rendered HTML, a typed metadata API and file-based sitemaps. What it does not give you is the discipline to fill all of it in on every route. This post is the checklist I work through on every Next.js 15 project, in the order I do it, with the code I actually use.

Everything here was checked against Next.js 15 in November 2026. If you are still on the Pages Router, most of the ideas carry over, but the APIs are different.

What does SEO mean in the Next.js App Router?#

SEO in the App Router means making sure every route returns complete HTML with a unique title, description, canonical URL and structured data, fast enough to pass Core Web Vitals.

The App Router is a good base for this. Server Components render on the server by default, so the HTML a crawler receives already contains your headings and text. Google can run JavaScript, but other crawlers, including several AI search crawlers, often do not. Content that only appears after a client-side fetch is content you are betting on.

The second part is metadata. In the App Router, metadata is not a component you drop into the page. It is a metadata object or a generateMetadata function exported from layout.tsx or page.tsx. Next.js merges these from the root layout down to the page and writes the result into the head. The official metadata docs list every field.

How do I set site-wide metadata in the root layout?#

Set metadataBase, a title template, a default description and Open Graph defaults once in app/layout.tsx, so every page inherits them.

metadataBase is the setting people skip and later regret. Without it, relative URLs in openGraph.images or alternates.canonical cannot be turned into absolute URLs, and Next.js warns at build time. The title template saves you from typing the site name on every page.

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

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Example Studio – Web Development",
    template: "%s | Example Studio",
  },
  description: "Web apps and websites built with Next.js, NestJS and PostgreSQL.",
  openGraph: {
    type: "website",
    siteName: "Example Studio",
    locale: "en_US",
  },
  twitter: { card: "summary_large_image" },
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Two details matter here. The lang attribute on the html element helps both search engines and screen readers. And the default title is only used by routes that do not set their own title, which should be almost none.

How do I give every page a unique title and description?#

Export a static metadata object from fixed pages and a generateMetadata function from dynamic routes such as /blog/[slug].

For a page like /about, a static object is enough:

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

export const metadata: Metadata = {
  title: "About",
  description: "Who we are, what we build and how to work with us.",
  alternates: { canonical: "/about" },
};

export default function AboutPage() {
  return <h1>About</h1>;
}

For dynamic routes, generateMetadata reads the route params and fetches the record. In Next.js 15, params is a Promise, so you must await it. I cover the patterns I use, including how to avoid fetching the same post twice, in generateMetadata patterns in Next.js 15.

A good title is under about 60 characters and starts with the thing the page is about. A good description is 140 to 160 characters and reads like an answer, not a list of keywords. Google rewrites descriptions often, but a clear one still gets used more than a vague one.

Do I need canonical URLs in Next.js?#

Yes: set alternates.canonical on every indexable page so that query strings, trailing slashes and tracking parameters all point to one URL.

Next.js does not add a canonical tag for you. Without one, /blog/post, /blog/post?utm_source=x and /blog/post/ can all be crawled as separate pages. Pick one form and stick to it. If you set trailingSlash in next.config.ts, make your canonicals and sitemap use the same form.

Filtered list pages are the most common trap. A /work?category=vue view is useful for people, but the canonical should stay /work unless the filtered page has its own unique content worth ranking.

How do I generate sitemap.xml and robots.txt?#

Create app/sitemap.ts and app/robots.ts; Next.js turns their return values into /sitemap.xml and /robots.txt at build time.

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

const SITE = "https://example.com";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPublishedPosts(); // { slug: string; updatedAt: string }[]

  return [
    { url: SITE, lastModified: new Date("2026-11-01") },
    { url: `${SITE}/blog`, lastModified: new Date("2026-11-01") },
    ...posts.map((post) => ({
      url: `${SITE}/blog/${post.slug}`,
      lastModified: new Date(post.updatedAt),
    })),
  ];
}
ts
// app/robots.ts
import type { MetadataRoute } from "next";

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

Keep lastModified honest. If every URL gets new Date() on every build, the date carries no signal. Take it from your CMS, your frontmatter, or the last git commit for that file. The file conventions are documented under sitemap.xml and robots.txt.

Do not block /_next/static in robots.txt. Google needs your CSS and JavaScript to render the page the way users see it.

How do I add structured data?#

Render a script tag with type="application/ld+json" from a Server Component, containing schema.org data such as Organization, BlogPosting and BreadcrumbList.

Next.js has no special API for JSON-LD, and it does not need one. You build a plain object, serialize it, and escape the less-than character so the content cannot break out of the script tag. I wrote a full guide with typed @graph examples in JSON-LD structured data in Next.js.

The rule that matters most: structured data must describe what is visible on the page. An FAQ in JSON-LD that is not on the page is a quality problem, not a ranking trick.

Does the rendering mode affect SEO?#

Rendering mode affects speed and freshness more than indexing, because static, ISR and dynamic pages all return full HTML to the crawler.

Where it matters is time to first byte and stability. A static page served from a CDN is fast everywhere. A dynamic page that waits on a slow database query can push Largest Contentful Paint over the 2.5 second threshold. My rule is to keep marketing pages, blog posts and case studies static or ISR, and only render per request when the page really depends on the request. The trade-offs are in ISR vs SSG vs SSR in Next.js 15.

How do I stop images from hurting Core Web Vitals?#

Use next/image with correct width, height or sizes, and mark the largest above-the-fold image with priority.

Images are the most common cause of a slow LCP and of layout shift on content sites. next/image handles resizing, modern formats and lazy loading, but it cannot guess which image is your hero. Details and config are in Next.js image optimization with next/image.

How should 404s, redirects and noindex work?#

Call notFound() for missing records, use permanent redirects for moved URLs, and set robots.index to false on pages that must not be indexed.

A missing blog post should return a real 404 status, not a 200 page that says "not found". In a Server Component, notFound() from next/navigation does that and renders your not-found.tsx:

tsx
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
    </article>
  );
}

When a URL changes, add a redirect in next.config.ts. permanent: true sends a 308, which search engines treat like a 301:

ts
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async redirects() {
    return [{ source: "/posts/:slug", destination: "/blog/:slug", permanent: true }];
  },
};

export default nextConfig;

Preview and staging deployments should never be indexed. I add this to the root layout metadata so it cannot be forgotten:

ts
// app/layout.tsx (merged into the metadata object shown earlier)
import type { Metadata } from "next";

const isProduction = process.env.VERCEL_ENV === "production";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  robots: isProduction ? { index: true, follow: true } : { index: false, follow: false },
};

What is the full checklist?#

Here is the list I run before calling a Next.js site ready for search, with where each item lives.

ItemWhere it livesHow I check it
metadataBase and title templateapp/layout.tsxNo build warning, titles end with site name
Unique title and descriptioneach page.tsxCrawl with Screaming Frog or a script, no duplicates
Canonical URLalternates.canonicalView source on three random pages
lang attributeroot layoutView source
sitemap.xmlapp/sitemap.tsSubmit in Search Console, zero errors
robots.txtapp/robots.tsFetch /robots.txt on production
JSON-LDServer Component script tagRich Results Test
Static where possibleroute segment configBuild output symbols
Images sized, hero prioritizednext/imageLighthouse LCP and CLS
Real 404 statusnotFound()curl -I on a bad slug
Redirects for moved URLsnext.config.tscurl -I shows 308
Previews noindexedroot layout robotsView source on a preview URL

I used the same list when planning the SaaS analytics dashboard concept, where the public marketing pages need to rank but the app pages behind login should not be indexed at all. If you want something small to practise on, open a post in my Markdown blog engine demo, a Next.js and MDX blog with sample posts, and run the view-source checks from this table against it. More of my Next.js work is collected on the Next.js skill page.

What should you do first?#

Open your production site, view the source of your three most important pages, and check four things: the title, the description, the canonical link and whether the main text is in the HTML. Fix whatever is missing in the root layout first, because one change there covers every route. Then add app/sitemap.ts with honest dates and submit it in Search Console. Those two steps take an afternoon and cover the most common SEO gaps on App Router sites.

Frequently asked questions

Is the Next.js App Router good for SEO?

Yes. The App Router renders Server Components to HTML on the server, so crawlers get full content without running JavaScript. It also has a typed Metadata API, file conventions for sitemap.xml and robots.txt, and static rendering by default. The risks come from your own choices, such as client-only content, missing canonicals or slow images, not from the router itself.

Do I still need next/head in the App Router?

No. next/head only works in the Pages Router. In the App Router you export a metadata object or a generateMetadata function from a layout.tsx or page.tsx file, and Next.js writes the title, meta and link tags into the document head for you. Mixing the two approaches does nothing useful, so remove next/head when you migrate a page.

How do I add a canonical URL in Next.js 15?

Set metadataBase once in the root layout, then add alternates.canonical in each page's metadata or generateMetadata return value. A relative path such as /blog/my-post is resolved against metadataBase into an absolute URL. Use the same canonical form everywhere: one protocol, one host, and one trailing slash policy, matching what your sitemap lists.

Should I put every page in sitemap.xml?

Put every page you want indexed and nothing else. Leave out drafts, preview routes, filtered list views with query strings, and pages marked noindex. Give each entry an accurate lastModified date taken from your content, not the build time, because search engines learn to ignore dates that change on every deploy without the page changing.

Where I used this

Keep reading

#nextjs6 min

ISR vs SSG vs SSR in Next.js 15: How to Choose

ISR vs SSG vs SSR in Next.js 15 App Router: what each rendering mode is, how Next.js picks one, a comparison table, working code and a decision checklist.

read →

#nextjs6 min

JSON-LD Structured Data in Next.js 15

How to add JSON-LD structured data in Next.js 15: a safe script component, typed schema-dts graphs with stable @id values, FAQ rules and how to test it.

read →