I relaunched my portfolio on the Next.js App Router, submitted the sitemap, and waited. A week later Google Search Console still said 0 pages indexed — everything sat in "Crawled – currently not indexed."
My first instinct was the usual: time and domain authority. Both are real for a new site. But before you blame Google, it's worth ruling out the self-inflicted version — because in my case there was one, and it's a trap that's very easy to fall into with the App Router's metadata API.
The gotcha: an inherited canonical
In the App Router you set metadata in layout.tsx or page.tsx. It's tempting to put your canonical URL once, in the root layout:
// app/layout.tsx — DON'T do this export const metadata: Metadata = { metadataBase: new URL("https://example.com"), alternates: { canonical: "/" }, }; Metadata inherits. So every route under that layout — /about, /projects, every post — renders <link rel="canonical" href="https://example.com/">. You've just told Google that every page on your site is a duplicate of the homepage. Google obliges and indexes… the homepage, maybe.
The fix is a per-route canonical. A tiny helper keeps it honest:
// app/lib/seo.ts import type { Metadata } from "next"; export function pageMetadata({ title, description, path }: { title: string; description: string; path: string; }): Metadata { return { title, description, alternates: { canonical: path }, // self-referencing, per route openGraph: { title, description, url: path }, }; } // app/about/layout.tsx export const metadata = pageMetadata({ title: "About", description: "…", path: "/about", }); Now each route points at itself. Verify it the way Google does — inspect a URL in Search Console and check that Google-selected canonical matches your user-declared canonical. If they disagree, nothing else you do will get that page indexed.
Three more App Router SEO fixes that actually moved the needle
1. Noindex your preview/alias domains. Vercel gives every deployment a *.vercel.app URL, and they're all live and crawlable. Left alone, they compete with your real domain as duplicates. Send a noindex header to every host except the canonical one — no middleware needed:
// next.config.mjs async headers() { return [{ source: "/:path*", missing: [{ type: "host", value: "example.com" }], headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }], }]; } 2. Self-host your Open Graph image. I'd been pointing og:image at a LinkedIn CDN URL — which expires and isn't fetchable by some scrapers (WhatsApp never rendered it). Generate it at the edge instead with next/og, so it's a real static asset at /opengraph-image:
// app/opengraph-image.tsx import { ImageResponse } from "next/server"; export const size = { width: 1200, height: 630 }; export default function OG() { return new ImageResponse((/* your JSX */), size); } 3. Add real structured data. A Person + BreadcrumbList JSON-LD block in your layout gives Google an entity to attach your name to. It's cheap and it's one of the few signals you fully control on a new site.
And the part that is just time
After all that, some pages still read "Crawled – currently not indexed," and that's the honest truth about a new site: crawl ≠ index. Google crawled them fine (fetch successful, indexing allowed) and simply hasn't decided they're worth storing yet. The levers that change its mind are mundane — a sitemap it trusts, a handful of referring links from pages it already crawls often, internally-linked hub pages, and a few weeks. "Request indexing" nudges a specific URL; it doesn't buy authority.
So: fix the canonical first (that's the bug), then be patient about the rest (that's the physics).
The code above is live on my site — developer.ericgitangu.com — and if you want to see the per-page canonicals and JSON-LD in the wild, the projects section renders a SoftwareSourceCode block per entry. I'm Eric Gitangu, a software engineer working on distributed and fintech systems; happy to compare notes in the comments.