From 27 to 143 Indexed Pages: Fixing Convex + Next.js 15 SSR for Google
A first-hand case study of why a Convex-backed Next.js 15 app was nearly invisible to Google, and the exact server-rendering changes that took it from 27 to 143 indexed pages. Covers the client-only content trap, the hybrid hydration pattern, and the robots.txt mistake that freezes pages in the index.
Primary Focus
webAI Tools Covered
What You'll Learn
- ✓Indexed Pages Stuck in the Twenties
- ✓How to Confirm It Is Happening to You
- ✓Fetch on the Server, Hydrate on the Client
- ✓Cache the Server Fetch
- ✓Give Listing Pages Real Server-Rendered Text
- ✓Noindex the Genuinely Thin Pages
Guide Curriculum
The Symptom — Google Sees an Empty Page
Learn key concepts
- •Indexed Pages Stuck in the Twenties2m
- •How to Confirm It Is Happening to You1m
The Fix — Hybrid Hydration
Learn key concepts
- •Fetch on the Server, Hydrate on the Client2m
- •Cache the Server Fetch1m
The Listing and Archive Pages
Learn key concepts
- •Give Listing Pages Real Server-Rendered Text1m
- •Noindex the Genuinely Thin Pages1m
The robots.txt Trap
Learn key concepts
- •Blocking a URL Does Not Remove It From the Index1m
- •Not Every "Error" Is Yours to Fix1m
Preview: First Lesson
The Symptom — Google Sees an Empty Page
Indexed Pages Stuck in the Twenties
The numbers tell the story plainly. Our Convex-backed Next.js 15 site sat at 27 indexed pages in Google Search Console for months, despite having dozens of long-form articles published. After the changes in this guide, that figure climbed to 143 — a confirmed Search Console count, not an estimate. Same content, same domain. The only thing that changed was what Google could actually see when it crawled.
The trap is specific to apps that fetch their content client-side. Convex is a real-time backend: the natural pattern is a React component that calls a query hook and renders the result once data arrives. That works beautifully for a logged-in user. It is nearly invisible to a search crawler, because the initial HTML the server sends contains a loading state — a table of contents, a spinner, a skeleton — and the actual article text only appears after JavaScript runs and the query resolves.
Google does execute JavaScript, but rendering is deferred, budget-limited, and unreliable for content that depends on a round-trip to a real-time backend. Our guide pages were shipping roughly a hundred words of table-of-contents text in the initial HTML while the real article — thousands of words — loaded client-side. To a crawler, those looked like thin pages. That is exactly the kind of page that earns a "low value content" judgment and never gets indexed.
Start learning with this comprehensive guide
This guide includes:
About the Author
Hiram Clark is the founder and managing editor of vybecoding.ai and sets editorial direction for the guides and news published here. Articles are drafted with AI assistance and edited before publication. He works hands-on with the AI development tools, workflows, and infrastructure covered on the site.
Full Guide Content
Complete lesson text — start the interactive course above for exercises and progress tracking.
Module 1The Symptom — Google Sees an Empty Page
1.1Indexed Pages Stuck in the Twenties
The numbers tell the story plainly. Our Convex-backed Next.js 15 site sat at 27 indexed pages in Google Search Console for months, despite having dozens of long-form articles published. After the changes in this guide, that figure climbed to 143 — a confirmed Search Console count, not an estimate. Same content, same domain. The only thing that changed was what Google could actually see when it crawled.
The trap is specific to apps that fetch their content client-side. Convex is a real-time backend: the natural pattern is a React component that calls a query hook and renders the result once data arrives. That works beautifully for a logged-in user. It is nearly invisible to a search crawler, because the initial HTML the server sends contains a loading state — a table of contents, a spinner, a skeleton — and the actual article text only appears after JavaScript runs and the query resolves.
Google does execute JavaScript, but rendering is deferred, budget-limited, and unreliable for content that depends on a round-trip to a real-time backend. Our guide pages were shipping roughly a hundred words of table-of-contents text in the initial HTML while the real article — thousands of words — loaded client-side. To a crawler, those looked like thin pages. That is exactly the kind of page that earns a "low value content" judgment and never gets indexed.
1.2How to Confirm It Is Happening to You
You do not need Search Console to diagnose this. Curl your own page and count the words in the raw response:
# Count visible words in the server-rendered HTML (no JS executed)
curl -s https://yoursite.com/your-article | sed 's/<[^>]*>//g' | tr -s ' \n\t' ' ' | wc -w
If a 2,000-word article returns a few hundred words of chrome and navigation, the body is client-only and crawlers are not seeing it. After our fix, the same command on a representative article returned 9,761 words — the full content, present in the initial HTML before a single line of client JavaScript ran. That is the number that matters.
Module 2The Fix — Hybrid Hydration
2.1Fetch on the Server, Hydrate on the Client
The solution is not to abandon Convex's reactivity. It is to fetch the initial data on the server and pass it into the client component as a prop, so the first paint already contains real content. The client component still hydrates and can subscribe to live updates, but it no longer starts from an empty state.
In Next.js 15 App Router, the page is a server component that fetches with Convex's server client and renders a client child with the data baked in:
// page.tsx — server component
import { fetchQuery } from 'convex/nextjs'
import { api } from '@/convex/_generated/api'
import ArticleClient from './ArticleClient'
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const initialArticle = await fetchQuery(api.articles.getBySlug, { slug })
return
}
// ArticleClient.tsx — 'use client'
// Render initialArticle immediately; optionally subscribe for live updates.
export default function ArticleClient({ initialArticle }: { initialArticle: Article }) {
// The body is in the DOM on first paint — crawlers see it without running queries.
return {/* render initialArticle.body here */}
}
We applied this hybrid pattern across every content surface: article detail pages, app detail pages, and profile pages all moved from "client fetch into a Loading shell" to "server fetch passed as initialData." The visible word count in the initial HTML went from a hundred to thousands per page.
2.2Cache the Server Fetch
Server-side fetching on every request would hammer your backend. Wrap the fetch in Next.js caching so popular pages serve from cache and revalidate on an interval:
import { unstable_cache } from 'next/cache'
const getArticle = unstable_cache(
async (slug: string) => fetchQuery(api.articles.getBySlug, { slug }),
['article-by-slug'],
{ revalidate: 1800, tags: ['articles'] }
)
This keeps the server render fast while still shipping full content in the HTML. Tag-based revalidation lets you bust the cache precisely when content changes rather than waiting out the interval.
Module 3The Listing and Archive Pages
3.1Give Listing Pages Real Server-Rendered Text
Detail pages were the biggest win, but listing pages — the index of apps, the news feed — had the same disease in milder form. A grid of cards rendered client-side gives a crawler almost nothing but a heading. We added a server-rendered editorial introduction to each major listing: a real heading and a few hundred words describing what the section is, how content is organized, and what a visitor can do there. That text lives in a server component, so it is always in the initial HTML regardless of whether the interactive grid below it has hydrated.
3.2Noindex the Genuinely Thin Pages
Not every page deserves to be indexed, and pretending otherwise hurts you. Empty category pages, near-empty tag archives, and auth-gated utility pages add nothing for a searcher and dilute crawl budget. We made these conditionally noindex: a category page with zero published articles returns robots: { index: false }, and we derive the sitemap's category list from actual content so empty categories never get submitted in the first place. The principle: index pages with unique value, and explicitly exclude the rest. A smaller index of substantial pages beats a large index padded with thin archives.
Module 4The robots.txt Trap
4.1Blocking a URL Does Not Remove It From the Index
This one is counterintuitive and cost us a recurring Search Console error. We had auto-generated social-preview image routes that Google had indexed as if they were pages. The obvious fix — block them in robots.txt — made things worse. Search Console reported them as "Indexed, though blocked by robots.txt," and the status never cleared.
The reason: robots.txt controls crawling, not indexing. Blocking a URL stops Google from visiting it, which means Google can never discover that the URL now returns a 404 or is just an image — so an already-indexed URL stays frozen in the index indefinitely. The correct fix is the opposite of the instinct: unblock the URL so the crawler can visit it, see the 404 or non-page content type, and drop it naturally. To remove a page from the index you must let Google crawl it and find a noindex directive or a genuine 404. Robots.txt is the wrong tool for de-indexing, every time.
4.2Not Every "Error" Is Yours to Fix
A closing piece of perspective. Search Console will flag "Page with redirect" for URLs that redirect — including the redirects you set up on purpose: www to apex, http to https, deprecated routes, deleted articles pointed at a listing. Those validations will "fail" forever, because the redirect is intentional and correct. Re-requesting validation on them is wasted effort. Learn to separate the issues that represent a real crawler problem you can fix from the ones that are Google narrating your own deliberate configuration back to you. The skill is not fixing every line in the report — it is knowing which lines are actually broken.