Your homepage is empty as far as Google is concerned
React and Next.js sites often ship a first HTML payload that contains one empty div. Google can execute JavaScript, but rendering is budgeted. This is verifiable with a single curl.
While auditing a content site, one command made everything obvious:
curl -s https://your-site.com/ | wc -c
33 KB.
That sounds fine. Then:
curl -s https://your-site.com/ | grep -o "<main[^>]*>" -A2 | head -5
Output:
<main class="flex-1">
<div id="root"></div>
The 33 KB contains no body copy. It is scripts, font preloads and CSS.
That is the technical explanation for “I published the content and it does not rank”.
Why this is a problem even though Google executes JS
Googlebot does render JavaScript. The common conclusion — “it will be fine” — is wrong in three ways.
1. Rendering is queued, not immediate
Crawl and render are separate stages. Google fetches HTML, stores it, and a render queue processes it later. That delay can be minutes or weeks, depending on authority, server speed and page count.
New and small sites are at the back of the queue. Content that arrives late might as well have not arrived.
2. Rendering has a budget
The number of pages Google will render per site is finite. A 126-page site that depends entirely on client rendering may only get part of its pages rendered; the rest are processed as the empty HTML that was fetched.
This is why some pages get indexed and others do not, with no apparent content difference.
3. Rendering failures are silent
A JS error, a timed-out API call, a blocked third-party script — any of these and Google sees a blank page.
Your users, on a normal browser with a normal connection, see nothing wrong.
That is the dangerous part: the failure is invisible to you.
Verify it in three minutes
Option A: raw HTML
curl -s https://your-site.com/ \
| python3 -c "
import sys, re, html
raw = sys.stdin.read()
m = re.search(r'<main[^>]*>(.*?)</main>', raw, flags=re.S|re.I)
seg = m.group(1) if m else raw
t = re.sub(r'<(script|style|noscript)[^>]*>.*?</\1>', ' ', seg, flags=re.S|re.I)
t = re.sub(r'<[^>]+>', ' ', t)
t = re.sub(r'\s+', ' ', html.unescape(t)).strip()
print('body chars:', len(t))
print(t[:300])
"
| Body characters | Verdict |
|---|---|
| < 300 | Severe — effectively empty |
| 300–1,000 | Insufficient — headings and fragments only |
| 1,000–3,000 | Marginal |
| > 3,000 | Normal |
Option B: disable JavaScript
DevTools → Ctrl+Shift+P → Disable JavaScript → reload. What you see is what Googlebot sees first.
Option C: view-source:
Search the page source for the first sentence of your homepage. If it is not there, the problem is confirmed.
What must be server-rendered
Not everything. The test is simple: is this text for reading or for clicking?
| Must be server-rendered | Can stay client-side |
|---|---|
| Body copy, product details, prices | Form interaction, live validation |
| H1 / H2 hierarchy | Animations, transitions, hover states |
| FAQ content | User-specific data after login |
| Internal links (nav, related) | Modals, drawers |
| Breadcrumbs | Live search suggestions |
| JSON-LD structured data | Analytics, A/B tests |
Three fix patterns
Pattern A: server component + client island
// app/product/[slug]/page.tsx — server component
import { getProduct } from "@/lib/products";
import { BuyButton } from "./buy-button"; // client component
export default async function Page({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug);
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p> {/* server: crawlable */}
<ul>{product.features.map(f => <li key={f}>{f}</li>)}</ul>
<BuyButton productId={product.id} /> {/* client: clickable */}
</main>
);
}
// buy-button.tsx
"use client";
export function BuyButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
// ...
}
The key is where you draw the "use client" boundary: on the smallest interactive unit, not on the page.
Pattern B: keep the interactive page, add server content in the layout
For pages that are inherently interactive (dashboards, generators, forms), full migration is expensive.
You do not need to touch the page. Add a content block in layout.tsx, which is a server component:
// app/dashboard/layout.tsx — server
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children} {/* client interaction, untouched */}
<SeoContentBlock {/* server: in the first payload */}
title="How this tool works"
blocks={[...]}
faqs={[...]}
/>
</>
);
}
I used this on three interactive pages: first-payload body text went from 46–58 characters to 3,677–7,786, with zero changes to business code.
Note that layout content is not affected by the child page’s Suspense boundary — which is exactly why it is more reliable than putting the block in page.tsx.
Pattern C: static generation (SSG / ISR)
Best option for content pages; it removes the server from the equation.
export const revalidate = 3600; // regenerate hourly
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
One risk: duplicated content
React hydration reuses server markup, so you normally will not get duplicates. What does cause problems:
// ❌ rendered by the server...
<h1>Product name</h1>
// ❌ ...and again by a client component
<ClientHeader /> // which also renders <h1>Product name</h1>
Result: two H1s in the HTML.
Check with:
curl -s https://your-site.com/ | grep -c "<h1"
More than one means you need to find the second source.
Checklist
[ ] curl the raw HTML, count <main> text (>1,000 to pass)
[ ] grep -c "<h1" — should be exactly 1
[ ] Disable JS and reload — is the content still there?
[ ] view-source: search for your first sentence
[ ] "use client" is on components, not whole pages
[ ] SEO content in layout is outside Suspense
[ ] Interactive pages have 800+ words of server-rendered copy
[ ] Structured data is server-rendered, not injected via useEffect
That last one is easy to miss: JSON-LD added inside useEffect may never reach Googlebot.
Finally
Server rendering solves “can it be read”. It does not solve “is it worth reading”.
On my own site, fixing the first payload (46 → 3,677 characters) moved all 21 stuck pages into the index within weeks. Organic traffic did not meaningfully increase, because the content was not valuable enough to anyone.
Make it readable first. Then make it worth reading. In the other order, neither works.
Does your site have these problems?
Run the free audit tool for a 30-second report, or just write to me and describe the situation — I will tell you whether it is worth fixing.