Sacred Lantern Studio
2026-11-07· 7 minSEOReactNext.jsSSR

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.