DEV Community

Arsany
Arsany

Posted on

Next.js App Router SEO checklist: 9 things I fixed on my own site

Last week I audited the marketing site for Ilytics, the analytics tool I've been building, after Search Console showed almost nothing indexed. The homepage that Googlebot received had one word on it. Fixing that led to a full pass over the site's SEO.

This is the checklist I ended up with. Every item is something I actually found and fixed on a Next.js 15 App Router site. Each one has a way to check your own site in under a minute.

1. See your page the way a crawler does

Before changing anything, look at the HTML your server sends, not what your browser renders after JavaScript runs.

curl -s -A "Googlebot" https://yoursite.com/ | sed 's/<[^>]*>/ /g' | wc -w
Enter fullscreen mode Exit fullscreen mode

This counts the words in the raw HTML. My homepage returned 1. After the fixes below it returned 375. If your number is tiny compared with what you see on screen, something is client-rendering your content.

2. useSearchParams can client-render your whole page

This was the big one. I had a pageview tracker that called useSearchParams(), wrapped in a <Suspense> that also wrapped {children} in the root layout.

The Next.js docs say that on a statically rendered route, useSearchParams makes the client component tree up to the closest Suspense boundary render on the client. My closest boundary contained the entire site.

The fix is to give the hook its own tiny boundary:

// app/providers.tsx
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <PostHogProvider client={posthog}>
      {children}
      <Suspense fallback={null}>
        <PageviewTracker />
      </Suspense>
    </PostHogProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now only the tracker waits for the browser. Everything else renders on the server.

3. Set metadataBase

Without it, relative Open Graph image URLs and canonicals can't be turned into absolute URLs. Next.js warns about this in the terminal, and the warning is easy to miss.

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://yoursite.com"),
  title: { default: "Your product: what it does", template: "%s | Your product" },
};
Enter fullscreen mode Exit fullscreen mode

4. One canonical per page

Each route should declare its own canonical, or Google picks one for you:

export const metadata: Metadata = {
  alternates: { canonical: "/pricing" },
};
Enter fullscreen mode Exit fullscreen mode

For dynamic routes, return it from generateMetadata using the slug.

5. Don't repeat the brand twice in titles

With a title.template of "%s | Brand", a page whose own title is "Privacy | Brand" ends up as "Privacy | Brand | Brand". I had this on two legal pages. Give pages only the part before the separator.

6. Pages with "use client" can't export metadata

My pricing and FAQ pages were client components, so their export const metadata did nothing. They fell back to the homepage title.

The fix is a small layout.tsx next to the page that exports the metadata. The page itself stays a client component.

// app/(marketing)/pricing/layout.tsx
export const metadata: Metadata = {
  title: "Pricing",
  description: "One plan, 14-day free trial.",
  alternates: { canonical: "/pricing" },
};
export default function Layout({ children }: { children: React.ReactNode }) {
  return children;
}
Enter fullscreen mode Exit fullscreen mode

7. Generate the sitemap from your real routes

A hand-written sitemap goes stale. app/sitemap.ts can build it from the same data your pages use:

import type { MetadataRoute } from "next";
import { DOCS } from "@/lib/docs";

export default function sitemap(): MetadataRoute.Sitemap {
  const base = "https://yoursite.com";
  const pages = ["", "/pricing", "/faq", "/docs"].map((p) => ({ url: base + p, lastModified: new Date() }));
  const docs = DOCS.map((d) => ({ url: `${base}/docs/${d.slug}`, lastModified: new Date() }));
  return [...pages, ...docs];
}
Enter fullscreen mode Exit fullscreen mode

Mine went from 4 URLs to 15 once the docs pages were included.

8. Only put true things in structured data

My JSON-LD had an aggregateRating left over from a template, for reviews that didn't exist. Google's structured data guidelines say markup has to reflect content that's actually on the page, and made-up ratings can get the page a manual action.

I removed it and kept a simple @graph with Organization, WebSite and SoftwareApplication. I also deleted some leftover geo meta tags for a city I've never been to.

9. Tell search engines when things change

After deploying, I submitted the sitemap in Search Console and pinged IndexNow (used by Bing and Yandex). IndexNow needs a key file in public/, then one request with your changed URLs. Search Console shows changes over days, not minutes, so check back after a week.

The order I'd do it in

  1. Run the curl word count.
  2. Fix any Suspense or client-rendering bailout.
  3. Add metadata, canonicals and titles.
  4. Add the sitemap and clean up structured data.
  5. Resubmit to Search Console and IndexNow.

The first two steps matter more than everything else combined. If the HTML is empty, nothing else on this list gets read.

Sources: Next.js useSearchParams docs, Next.js metadata docs, Google structured data guidelines, IndexNow.

Arsany

Top comments (0)