"Discovered — currently not indexed."
That is what Google Search Console says about a page it knows exists but has decided not to include in search results. Not "indexing pending." Not "crawled successfully." Just: located, noted, and largely disregarded.
My /writing and /work pages. The ones with all the actual content. Google had found them, filed them away, and moved on.
The homepage was doing fine. Position 2.25 average, 12 impressions, 6 clicks over the last three months. Not impressive, but real — someone searching for me was finding me. The pages with the actual writing and case studies, though? They might as well not exist.
I had a theory.
My portfolio uses an unusual routing setup. When a request comes in, a proxy layer checks the User-Agent header and routes it to one of two React trees: /d/* for desktop browsers, /m/* for mobile. The desktop and mobile versions are genuinely different — separate component hierarchies, different layouts, different amounts of information shown. It's a deliberate architecture for two radically different UX contexts.
From the code perspective this looks completely normal. The desktop writing page lives at app/d/writing/page.tsx. Mobile at app/m/writing/page.tsx. Both export metadata, both render content, everything works.
From Google's perspective, these are two separate URLs. Both exist. Both serve content. They happen to share the same title and description — but Google doesn't know they're supposed to be the same page. It has to figure that out itself.
And apparently it gave up.
What Was Missing
The concept is the canonical tag — a <link> element in the page <head> that tells search engines: "multiple URLs serve this content, but this one is the real one."
In Next.js App Router, you declare it in metadata:
export const metadata = {
alternates: {
canonical: '/writing',
},
}Without this, when Google's crawler encounters /d/writing and /m/writing, it's looking at two doors into the same room with no sign indicating which one is the entrance. It has to make a call: pick one and suppress the other, try to index both (and split link equity between them), or park everything under "discovered" and wait for a signal that never arrives.
My working theory for why the homepage was fine: there's only ever one /. The UA routing produces /d/* and /m/* variants for every inner page, but the root path has no prefix — no conflict, no ambiguity, no problem. The homepage got indexed because it looked like one URL to Google. The inner pages got shelved because they looked like two.
The Fix Was Three Things
Canonical tags across every route.
Every list page (/work, /writing) and every detail page (/work/[slug], /writing/[slug]) needed an alternates.canonical pointing to the unprefixed path. Both shell variants had to declare the same canonical — that's how Google learns they're the same page, not competitors.
For individual posts and case studies, the canonical has to be built dynamically from the slug:
export async function generateMetadata({ params }) {
return {
alternates: {
canonical: `/writing/${params.slug}`,
},
// ... rest of metadata
}
}Since both desktop and mobile case studies share the same implementation file, one change covered both variants. For the list pages I updated four files — app/d/writing, app/m/writing, app/d/work, app/m/work — each declaring the same canonical as the other.
Case studies added to the sitemap.
While I was in there, I noticed sitemap.ts was mapping posts but not case studies. The /work index page was listed. The individual case study detail pages — three of them — were not. Sitemaps are how you tell Google what to prioritize; missing them means Google finds those pages eventually through crawl, but on its own schedule, which could be a while.
The existing listCaseStudies() function already enumerated them from the source directory. Wiring it into the sitemap was straightforward — fetch posts and studies in parallel, map both into entries:
const [posts, studies] = await Promise.all([
getAllPosts().catch(() => []),
listCaseStudies().catch(() => []),
])JSON-LD Person schema on the homepage.
There's a separate and more stubborn problem: "Vaibhav Verma" as a search query returns several different people — a Java developer, an AI entrepreneur, a product designer. My site doesn't appear in the top ten for my own name.
Schema.org's Person type is the structured data signal that tells search engines who a page is about: name, job title, email, linked profiles. It's what feeds Google's knowledge panel and helps the crawler associate a specific person-entity with a specific domain. I built a component that emits this as inline JSON-LD on both home page variants.
I'm not optimistic about jumping to the front of the "Vaibhav Verma" queue. There are people with the same name who have older domains, more backlinks, and more indexed content. But the signal is now at least present, which is a prerequisite for anything changing at all.
The Part Where I Admit I Don't Know If This Worked
Canonical tags are a signal to the crawler — they don't force a recrawl or trigger an immediate re-evaluation. The inner pages might stay at "Discovered — currently not indexed" for weeks before Googlebot comes around again with better context.
What I can say is that the previous state was wrong in a diagnosable way. The canonical documentation is explicit: when multiple URLs serve the same content without a canonical declaration, Google either picks one arbitrarily or doesn't commit to either. The symptom — homepage indexed, inner pages not — matches the failure mode.
Also, the fact that the homepage was indexed at all is actually useful information. It means the site isn't being blocked, the DNS resolves, pages render correctly. The architecture isn't the problem. One specific part of the SEO infrastructure was incomplete.
When This Comes Up
This issue isn't unique to UA routing. It shows up any time you serve the same conceptual page at multiple URLs:
- A/B testing where variants live at different paths
- Locale or language versions without
hreflangand canonical setup - Staging or preview environments that accidentally get crawled
- Pagination handled as separate pages without a canonical to the first
The failure mode is the same across all of them: multiple URLs, similar content, no explicit declaration of which one is real. And the fix is the same too: canonical tags. In Next.js App Router they live in alternates inside generateMetadata. They're cheap to add, and every page that declares one is one fewer ambiguity for the crawler.
The thing that caught me was that I had been thinking about routing as a UX and infrastructure concern, separate from SEO. The proxy routes the traffic; the pages render the content; SEO is about what the content says. But the routing architecture leaks into SEO directly. /d/writing is part of the URL, and Google treats URLs as identities.
The architecture was correct. It does exactly what it's supposed to do. The problem was that building a site with multiple URLs per page creates an implicit contract: you have to tell search engines which one is canonical.
That is in the documentation. It is also exactly the kind of thing that slips when you're focused on whether the component tree renders correctly on a 390px screen.
The canonical tag is not for users. It's the note you leave for the crawler.
Leave it.