# Next.js SEO checklist for 2026: 12 fixes for the App Router

Most Next.js sites that don't rank aren't missing a secret trick. They're missing basics the framework already gives you: a unique title per page, one canonical URL, a sitemap with honest dates, and content that's actually in the HTML.

This is the checklist I run on every App Router site, including this one. Each item comes with code you can copy.

> **Short version:** set `metadataBase`, give every page its own title and description, pick one canonical host, generate `sitemap.ts` and `robots.ts`, add JSON-LD, keep one real H1 with real text, make sure your content is server-rendered, and set up Search Console and Bing.

## 1. Set `metadataBase` and a title template

In the root layout, tell Next.js your production URL once. Every relative URL in your metadata (canonicals, Open Graph images) is resolved against it.

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

export const metadata: Metadata = {
  metadataBase: new URL("https://www.example.com"),
  title: { default: "Example — What you do, where you do it", template: "%s — Example" },
  description: "One or two sentences a searcher would actually click on.",
};
```

With the template, a page that sets `title: "Pricing"` shows up in search as "Pricing — Example", so your brand is on every result without repeating it by hand.

## 2. Give every page its own title and description

Duplicate titles are the most common problem I find in audits. For dynamic routes, use `generateMetadata`:

```tsx
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const post = await getPost((await params).slug);
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${post.slug}` },
  };
}
```

Keep titles around 50–60 characters with the main keyword near the start. Descriptions don't change rankings directly, but they decide whether people click, so write them like a small ad: who it's for, what they get, one proof point.

## 3. Pick one canonical host and redirect the rest

`example.com`, `www.example.com`, your `*.vercel.app` address and `http://` are four copies of the same site as far as Google is concerned. Pick one, use it as `metadataBase`, add a canonical to every page (as above) and permanently redirect the others.

On Vercel, set the primary domain in the dashboard. For an old deployment address, a host-based redirect in `next.config.ts` does the job. This one sends an old Vercel URL, and everything it ranked for, to the real domain:

```ts
// next.config.ts
async redirects() {
  return [
    {
      source: "/:path*",
      has: [{ type: "host", value: "old-project.vercel.app" }],
      destination: "https://www.example.com/:path*",
      permanent: true,
    },
  ];
}
```

## 4. Generate the sitemap, with honest dates

```ts
// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPosts();
  return [
    { url: "https://www.example.com", lastModified: "2026-10-24" },
    ...posts.map((p) => ({
      url: `https://www.example.com/blog/${p.slug}`,
      lastModified: p.updated ?? p.date,
    })),
  ];
}
```

Two details matter. Google ignores `priority` and `changeFrequency`, so don't spend time tuning them. It does use `lastModified`, but only while it stays accurate. If every URL shows the build date on every deploy, Google learns to ignore it. Change the date when the content changes, and only then.

## 5. Add `robots.ts`, and don't block what you want

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

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

If you want to show up in ChatGPT search and Perplexity answers, keep their crawlers (`OAI-SearchBot`, `PerplexityBot`) allowed. Also check your hosting firewall or CDN: some bot-protection settings block AI crawlers even when `robots.txt` allows them. I go deeper on this in [how to get cited by ChatGPT, Perplexity and Google AI Mode](https://www.vishalsolanki.online/blog/get-cited-by-chatgpt-perplexity-ai-search).

## 6. Describe the page with JSON-LD

Structured data tells search engines what a page is: a person, a business, an article, a product. Render it as a script tag from a server component:

```tsx
const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: post.title,
  datePublished: post.date,
  author: { "@type": "Person", name: "Your Name", url: "https://www.example.com" },
};

return (
  <script
    type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(schema).replace(/</g, "\\u003c") }}
  />
);
```

The `.replace` escapes `<` so no string inside your data can close the script tag early. The Next.js docs recommend it. Check the result with Google's Rich Results Test.

For a personal or freelance site, a `Person` and a `ProfessionalService` on the homepage plus a `BlogPosting` on each post covers most of what matters. Only mark up what's visible on the page.

## 7. One real H1, with real text

Search engines read the text inside your headings, not what they look like. Until recently, Google read my own homepage H1 as "I BUILD 01 IDEA THINGS 02 INTERACTION FOR THE WEB. 03 PRODUCT", because an animated word swap lived inside the heading. Visitors never saw that jumble. Crawlers saw nothing else.

The fix was to draw the decorative words with CSS (`::before` and `::after` with `content: attr(data-word)`), so the heading's text is simply "I build things for the web." The animation looks exactly the same.

- One `<h1>` per page.
- No decorative fragments, counters or icons with text inside it.
- Don't hide keyword text from visitors. Google treats hidden text as spam.

## 8. Make sure the content is in the HTML

Server components render to HTML by default, which is exactly what you want. Problems start when important text only appears after client-side JavaScript runs: inside a `useEffect`, behind a tab that only mounts on click, or drawn on a canvas. Google can render JavaScript, but later and less reliably, and most AI crawlers don't run it at all.

The quick test: fetch the page the way a crawler does and search for a sentence you care about.

```bash
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://www.example.com/ | grep -c "a sentence from your page"
```

If it prints `0`, that text isn't in the HTML a crawler receives.

## 9. Open Graph images per page

Add an `opengraph-image.tsx` file to a route segment and Next.js generates the image and the meta tags for you:

```tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);
  return new ImageResponse(
    <div style={{ display: "flex", fontSize: 64, padding: 80 }}>{post.title}</div>,
    size,
  );
}
```

It won't change your rankings, but links shared on LinkedIn, WhatsApp and X look like something worth opening instead of a grey box.

## 10. Speed: images, fonts and third-party scripts

Core Web Vitals feed into ranking, and they decide whether visitors stay long enough to read anything.

- **Images:** use `next/image` with `width` and `height` (or `fill` inside a sized parent) so space is reserved. Add `preload` to the one image that is your largest paint. In Next.js 16, `preload` replaced the old `priority` prop.
- **Fonts:** use `next/font`. It self-hosts the font and generates a size-matched fallback, which removes most font-swap layout shift.
- **Third-party scripts:** load analytics and tag managers late. On this site, Google Tag Manager loads on the first interaction, or after a few seconds if there isn't one, so it never competes with the first paint.

The full playbook is in [Core Web Vitals in 2026: fixing LCP, INP and CLS in Next.js](https://www.vishalsolanki.online/blog/core-web-vitals-nextjs-lcp-inp-cls).

## 11. Real 404s and clean redirects

- A missing page must return a **404 status**, not a friendly page with a 200. Call `notFound()` for unknown slugs, and set `dynamicParams = false` on statically generated routes so unknown URLs 404 automatically.
- Add an `app/not-found.tsx` that matches your design and links to your most useful pages.
- Use permanent redirects (308) when a URL has moved for good and temporary ones (307) when it hasn't.
- Avoid redirect chains. Each extra hop costs crawl time and a little speed for visitors.

## 12. Tell Google and Bing the site exists

- Verify your domain in **Google Search Console** with a DNS record (that covers every subdomain and protocol), submit `sitemap.xml` and request indexing for your key pages.
- Import the property into **Bing Webmaster Tools**. Bing also powers Microsoft Copilot and feeds ChatGPT search, so it's worth the two minutes.
- Check the Pages report weekly. "Crawled – currently not indexed" usually means a page is thin, duplicated or not linked from anywhere.

## The checklist

| # | Check | Where |
|---|---|---|
| 1 | `metadataBase` and a title template | `app/layout.tsx` |
| 2 | Unique title and description on every page | `generateMetadata` |
| 3 | One canonical host, the rest redirected | `alternates.canonical`, `redirects()` |
| 4 | Sitemap with accurate `lastModified` | `app/sitemap.ts` |
| 5 | Search and AI crawlers allowed | `app/robots.ts`, CDN or firewall rules |
| 6 | JSON-LD, validated | Server component script tag |
| 7 | One real H1 per page | Your components |
| 8 | Key text in the server HTML | The `curl` test |
| 9 | Open Graph image per page | `opengraph-image.tsx` |
| 10 | Good Core Web Vitals | PageSpeed Insights, Search Console |
| 11 | Real 404s, no redirect chains | `not-found.tsx`, `notFound()` |
| 12 | Search Console and Bing set up | Their dashboards |

## FAQ

### Is Next.js good for SEO?

Yes. Server rendering, the Metadata API, file-based sitemaps and robots, and built-in image and font optimisation cover most technical SEO out of the box. What it can't do is choose your keywords, write your content or earn links. That part is still on you.

### Do I still need a package like next-seo?

Not with the App Router. The built-in Metadata API covers what `next-seo` used to do for the Pages Router.

### How long until a new Next.js site ranks?

Your own name usually ranks within a few weeks of being indexed. Competitive service keywords take months of useful content and links. Search Console shows you where you stand long before the traffic arrives.

---

Want this checklist run on your site, with the fixes done for you? [That's what I do](https://www.vishalsolanki.online/#contact).

*Originally published on [vishalsolanki.online](https://www.vishalsolanki.online/blog/nextjs-seo-checklist).*

