Back to all articles
Next.js SEOSEO for a Next.js blogSEO for an MDX blogMarketing for developers

Next.js SEO: The App Router Checklist for an MDX Blog (Metadata, Sitemap, Canonicals)

Next.js SEO for an App Router blog: metadata, canonicals, sitemap.ts, robots.ts, static rendering, JSON-LD, redirects and noindex. A checklist with code.

Oct 27, 20268 min read

Next.js SEO (or "nextjs seo", as most people type it) comes down to six things the framework makes easy but does not do for you: unique metadata per page, one canonical URL per page, a sitemap generated from your real content, server-rendered HTML for the text that matters, structured data, and clean redirects or noindex for pages you retire. Next.js already ships fast, server-rendered pages. The rest is configuration you set once in the App Router, plus the content work no framework can do.

This guide is for a developer running a blog or docs on Next.js with MDX files in the repo. Each section is one item on the checklist, with the code that sets it. At the end is what to watch in Search Console once it ships, because the setup is the cheap part.

The checklist at a glance

| Item | Where it lives (App Router) | What breaks if you skip it | |------|------------------------------|-----------------------------| | Title and description per page | metadata or generateMetadata in page.tsx | Every post shares the layout title; Google rewrites snippets | | Base URL | metadataBase in app/layout.tsx | Relative canonical and Open Graph URLs cannot resolve | | Canonical | alternates.canonical per page | Query-string and trailing-slash variants compete with each other | | Sitemap | app/sitemap.ts | New posts wait for Google to find them through links | | robots.txt | app/robots.ts | Preview or staging paths get crawled; sitemap not advertised | | Static rendering | generateStaticParams on [slug] | Slower responses; posts depend on a runtime data source | | Structured data | JSON-LD <script> in the page | No article or FAQ rich-result eligibility | | Social images | opengraph-image.tsx or openGraph.images | Shared links show no preview | | Retired pages | redirects() in next.config.js or robots: { index: false } | Old URLs 404 or keep competing with the page that replaced them |

Metadata: one title and description per page

In the App Router, a static page exports a metadata object and a dynamic route exports generateMetadata. For an MDX blog the dynamic version reads the post's frontmatter:

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

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${slug}` },
  };
}

Two habits keep this clean. Put a title template in the root layout (title: { template: "%s | My Site", default: "My Site" }) so each post only sets its own part. And treat the frontmatter description as a required field: a missing one leaves Google to pick a snippet from the body, which is often the first paragraph of boilerplate.

Recent Next.js versions pass params as a promise, which is why the example awaits it. On older versions it is a plain object.

metadataBase and canonical URLs

Set metadataBase once, in app/layout.tsx:

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
};

Every relative URL in metadata (canonical, Open Graph image, alternates) is resolved against it. Without it, a relative canonical cannot become an absolute URL.

The canonical tells Google which URL is the real one when several variants serve the same page: ?ref=twitter, a trailing slash, an uppercase slug, a preview domain. Point every post at its own clean path. Do not point all posts at the blog index or the home page; that asks Google to drop them.

sitemap.ts built from your posts

A sitemap is the cheapest way to tell Google a new post exists. In the App Router it is a file that returns data:

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();
  return [
    { url: "https://example.com", lastModified: new Date() },
    ...posts
      .filter((p) => !p.draft && !p.noindex)
      .map((p) => ({
        url: `https://example.com/blog/${p.slug}`,
        lastModified: new Date(p.updated ?? p.date),
      })),
  ];
}

Build it from the same function your blog index uses, so the sitemap and the site never disagree. Leave out drafts and noindexed posts: a sitemap that lists a URL whose page says noindex sends Google mixed signals. Then submit /sitemap.xml once in Search Console.

robots.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 it short. robots.txt controls crawling, not indexing: a disallowed URL can still appear in results if other pages link to it. To keep a page out of the index, use noindex (below) and leave it crawlable.

Render the content on the server

Next.js renders server components and the first load of client components to HTML, so a normal MDX post is already in the HTML Google fetches. The trap is content loaded in the browser after the page mounts, for example post text fetched inside useEffect. That text is not in the initial HTML.

For a blog, render posts at build time. generateStaticParams lists the slugs so each post becomes a static page:

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

Pair it with notFound() for an unknown slug, so a typo URL returns a real 404 instead of an empty 200 page.

Structured data with JSON-LD

The Next.js docs suggest rendering JSON-LD as a script tag inside the page component:

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: post.title,
  datePublished: post.date,
  dateModified: post.updated ?? post.date,
};

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

Generate it in one component from frontmatter, not by hand in each MDX file. If posts carry an FAQ in frontmatter, the same component can emit FAQPage markup that always matches the visible FAQ.

Open Graph images

Either set openGraph.images in metadata or add an opengraph-image.tsx next to the route and return an ImageResponse from next/og. A generated image per post (title on a branded background) means every shared link gets a preview without anyone making images by hand. It does not change rankings directly; it changes whether people click a shared link.

Retiring pages: redirect or noindex

A blog that has been running for a year collects posts that overlap or that nobody searches for. Next.js gives you two tools:

  • Redirect a page that has a clear replacement. In next.config.js, redirects() with permanent: true sends a permanent redirect (Next.js uses status 308) from the old slug to the page that absorbed it.
  • Noindex a page you want to keep online but out of search: robots: { index: false } in that page's metadata, and drop it from the sitemap.

The decision is the hard part, and it should come from Search Console data, not from a hunch. A redirect is the right call when two pages rank for the same queries; a noindex fits a page that has been live for months with no clicks and almost no impressions. Merging two pages just because their titles look similar can cost you the traffic both had. If you suspect overlap, our SEO for side projects piece shows which page types actually earn clicks.

After it ships: what to watch in Search Console

The setup above is a one-time job. What moves traffic is the weekly loop on top of it:

  1. Pages report. Check that new posts move from "Discovered" to "Indexed". A post stuck at "Crawled - currently not indexed" usually needs a better answer to its query, not more technical work.
  2. Queries per page. Look for a post getting impressions for a query it barely addresses. That is your next section or your next post.
  3. Low CTR at good positions. A post on page one with few clicks usually needs a sharper title or description, which is a one-line frontmatter edit.
  4. Overlap. Two posts showing up for the same queries are a candidate for a merge and a redirect.

A short, fixed routine beats an occasional big audit; our weekly SEO workflow lays one out.

Doing the loop from your editor

Because an MDX blog is just files in a repo, every fix in this guide is a code change: a frontmatter edit, a new MDX file, a line in redirects(). That makes it a good fit for a coding agent, as long as the agent works from real Search Console data and ships changes as a pull request you review. That is the idea behind an SEO CLI: search data printed as JSON in the terminal, so an agent can read it. If you already use Claude Code, this walkthrough shows a run with hard limits on what the agent may change.

FAQ

Is Next.js good for SEO?

Yes. Next.js renders pages on the server or at build time, so search engines get complete HTML. What it does not do for you is the content work: unique titles, canonicals, a sitemap that lists the right URLs, and pages that answer a real query.

How do I set a canonical URL in the Next.js App Router?

Set metadataBase once in the root layout, then return alternates.canonical from each page's metadata or generateMetadata, for example /blog/my-post. Next.js resolves the relative path and renders the canonical link tag.

Does Next.js generate a sitemap automatically?

Not by default. Add app/sitemap.ts that returns a list of URLs, and Next.js serves it at /sitemap.xml. Build the list from the same function that reads your posts.

How do I noindex a page in Next.js?

Return robots: { index: false } from that page's metadata. Do not also block the URL in robots.txt, or Google cannot crawl the page to see the noindex.

Do client components hurt SEO in Next.js?

Not by themselves: client components are still rendered to HTML on the server for the first load. What hurts is content that only appears after a fetch in useEffect.

PageSeeds Hosted

$99/month per site

We run the weekly SEO pass on your site and send the work to your repo as one pull request. You review it and merge.

Frequently Asked Questions

Is Next.js good for SEO?

Yes. Next.js renders pages on the server or at build time, so search engines get complete HTML. What it does not do for you is the content work: unique titles, canonicals, a sitemap that lists the right URLs, and pages that answer a real query. Those you configure or write yourself.

How do I set a canonical URL in the Next.js App Router?

Set metadataBase once in the root layout, then return alternates.canonical from each page's metadata or generateMetadata, for example '/blog/my-post'. Next.js resolves the relative path against metadataBase and renders the canonical link tag.

Does Next.js generate a sitemap automatically?

Not by default. In the App Router you add app/sitemap.ts that returns a list of URLs, and Next.js serves it at /sitemap.xml. For an MDX blog, build that list from the same function that reads your posts, so a new post is in the sitemap the moment it exists.

How do I noindex a page in Next.js?

Return robots: { index: false } from that page's metadata. Next.js renders a robots meta tag with noindex. Do not also block the URL in robots.txt, or Google cannot crawl the page to see the noindex.

Do client components hurt SEO in Next.js?

Not by themselves, because client components are still rendered to HTML on the server for the first load. What hurts is content that only appears after a fetch in useEffect: that text is not in the initial HTML.

Category: guides