In Astro vs Lovable for SEO I wrote about a client site dropping out of Google's index because Googlebot was getting empty HTML shells from a CSR React app. The fix was a custom prerender layer — a build-time script that injects real content into the static HTML before the JS bundle runs.
I built it, deployed it, and moved on. The prerender was in the pipeline. I assumed it was working.
Six months later I checked GSC on the site's four main directory pages. Zero impressions. Not low — zero. Across the entire period.
What the pipeline was supposed to do
The build command in package.json:
"build": "vite build && node scripts/prerender.mjs"
After Vite builds the React bundle, the prerender script runs. It fetches live data from Supabase, then walks every route and injects real content — titles, canonicals, descriptions, and actual listings — directly into the HTML files in dist/.
┌─────────────────────────────────────────────────────────┐
│ INTENDED PIPELINE │
├─────────────────────────────────────────────────────────┤
│ │
│ vite build → dist/ HTML shells │
│ │ │
│ ▼ │
│ prerender.mjs → fetch Supabase data │
│ │ │ │
│ │ ┌────────▼────────┐ │
│ │ │ category A │ │
│ │ │ category B │ │
│ │ │ category C │ │
│ │ │ category D │ │
│ │ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ inject into dist/ → real HTML content │
│ │
│ Googlebot sees: listings, titles, descriptions │
└─────────────────────────────────────────────────────────┘
The theory was sound. The implementation had a silent failure mode I never tested for.
What was actually happening
The prerender script fetched data like this:
async function fetchListings() {
const url = process.env.VITE_SUPABASE_URL;
const key = process.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) {
console.warn(' ⚠ Supabase env vars missing — skipping live fetch');
return [];
}
// ... fetch from Supabase
}
If the env vars weren't present, it logged a warning and returned an empty array. Then the HTML builder received an empty array and used its fallback:
function buildListingsHtml(items) {
if (!items.length) return '<p>Browse our verified listings below.</p>';
// ... build real listing HTML
}
The prerender ran. It produced output. Every page got a title, a canonical URL, and a meta description. But the body content was a single generic sentence.
┌─────────────────────────────────────────────────────────┐
│ WHAT ACTUALLY HAPPENED │
├─────────────────────────────────────────────────────────┤
│ │
│ Vercel build starts │
│ │ │
│ ▼ │
│ prerender.mjs runs │
│ │ │
│ ▼ │
│ process.env.VITE_SUPABASE_URL → undefined │
│ │ │
│ ▼ │
│ ⚠ warning logged, empty array returned │
│ │ │
│ ▼ │
│ buildListingsHtml([]) → fallback placeholder │
│ │ │
│ ▼ │
│ dist/category-a/index.html injected with: │
│ <p>Browse our verified listings below.</p> │
│ │
│ Googlebot sees: one sentence, no listings │
└─────────────────────────────────────────────────────────┘
The build logs showed the warning. I wasn't reading build logs. The deploy succeeded. Vercel reported green. The pages looked correct when I opened them in a browser — React hydrated immediately and replaced the placeholder with live data from Supabase. Only a Googlebot crawl or a raw curl would have caught it.
Finding it
One of the four directories was getting consistent clicks in GSC — not many, but steady. The others showed nothing at all. That partial signal was actually what made the problem easy to dismiss for months. One section performing feels like evidence the system is working. It isn't — it just means one placeholder happened to have enough content to be useful to Google, while the others didn't.
The directory that was ranking had a more descriptive static placeholder — a full sentence explaining what the section contained. The others had a single generic line. Google indexed what it could work with and ignored the rest.
Once I looked at the pattern across all four, the gap was obvious. I ran a direct check:
curl -s "https://example.com/category-a" | grep -i "ssr-prerender" -A 10
Output:
<div id="ssr-prerender" style="font-family:sans-serif;...">
<h1>Category A Directory</h1>
<p>Browse our verified listings below.</p>
<p><a href="https://example.com">Site Name</a> — Directory</p>
</div>
One sentence. No listing names, no descriptions, no links to individual detail pages. Three of the four directories looked exactly like this.
The prerender had been injecting this placeholder on every single deploy since the site launched.
Root cause: why it works locally but fails on Vercel
Vercel does not read your local .env file during builds. It uses environment variables configured in the Vercel dashboard under Project Settings → Environment Variables.
The Supabase credentials existed locally. They were not in Vercel. The prerender script called process.env.VITE_SUPABASE_URL, got undefined, warned quietly, and continued.
┌────────────────────────────┐ ┌────────────────────────────┐
│ Local machine │ │ Vercel build │
├────────────────────────────┤ ├────────────────────────────┤
│ │ │ │
│ .env │ │ Environment Variables │
│ VITE_SUPABASE_URL=... ✓ │ │ (none configured) ✗ │
│ VITE_SUPABASE_KEY=... ✓ │ │ │
│ │ │ process.env.VITE_... │
│ prerender works locally │ │ → undefined │
│ │ │ → silent fallback │
└────────────────────────────┘ └────────────────────────────┘
This is the part that made it easy to miss: the prerender worked perfectly in local builds. vite build && node scripts/prerender.mjs locally produced real content because the .env file was present. The failure only happened in the Vercel build environment, which I never tested directly.
The fix
Two parts.
1. Add env vars to Vercel. In the Vercel dashboard: Settings → Environment Variables → add VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY for all environments (Production, Preview, Development).
VITE_SUPABASE_PUBLISHABLE_KEY is the Supabase anon key — designed to be public. Vercel warns that keys prefixed with VITE_ may be exposed to the browser. This is intentional; the anon key is already in every user's browser via the JS bundle. Row Level Security policies handle actual access control.
2. Extend the prerender to cover all directories. The original script only fetched one category. I refactored the fetch into a shared helper:
async function supabaseFetch(table, select, order = 'name') {
const url = process.env.VITE_SUPABASE_URL;
const key = process.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) {
console.warn(' ⚠ Supabase env vars missing — skipping live fetch');
return [];
}
try {
const res = await fetch(
`${url}/rest/v1/${table}?select=${encodeURIComponent(select)}&order=${order}&limit=200`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.warn(` ⚠ ${table} fetch failed: ${e.message}`);
return [];
}
}
Each directory now has its own HTML builder that receives live data and produces real listings for Googlebot:
┌─────────────────────────────────────────────────────────┐
│ FIXED PIPELINE │
├─────────────────────────────────────────────────────────┤
│ │
│ Vercel build │
│ │ │
│ ▼ │
│ process.env.VITE_SUPABASE_URL → set ✓ │
│ │ │
│ ▼ │
│ supabaseFetch('table_a') → N listings │
│ supabaseFetch('table_b') → N listings │
│ supabaseFetch('table_c') → N listings │
│ supabaseFetch('table_d') → N listings │
│ │ │
│ ▼ │
│ each builder produces <ul> of real listings │
│ with names, descriptions, prices, links │
│ │ │
│ ▼ │
│ Googlebot sees: real content on first request │
└─────────────────────────────────────────────────────────┘
Verifying prerender output
After any prerender change, verify the output before assuming it worked:
# Check a directory page for real content
curl -s "https://yoursite.com/your-directory" | grep -i "ssr-prerender" -A 20
# If you see a single generic sentence — the fetch failed
# If you see <h3> listing names — it's working
Locally, after a build:
npm run build
grep -A 20 'ssr-prerender' dist/your-directory/index.html
If the output is a placeholder, check whether env vars are available in the build context. Don't assume the warning in build logs is acceptable — treat it as a failure.
What this cost
Six months of Googlebot crawling directory pages with no real content. Every crawl budget spent on those pages returned nothing indexable. The pages weren't penalised — they just didn't exist in Google's index.
The framer-motion incident taught me to add synthetic monitoring for broken UI. This one taught me to verify build output directly, not just check that the script ran.
┌─────────────────────────────────────────────────────────┐
│ MONITORING GAPS, UPDATED │
├─────────────────────────────────────────────────────────┤
│ │
│ ✓ UI monitoring → Playwright, every 2h │
│ ✓ GSC monitoring → rankings, index status │
│ ✓ Build verification → curl prerendered pages │
│ after every pipeline change │
│ │
│ Still blind: │
│ ✗ Build log scanning → automated warning detection │
│ ✗ Data freshness → confirm live data not stale │
└─────────────────────────────────────────────────────────┘
The fix is running. The build log now shows row counts for every table fetch — real numbers, not zero. That's what it should have shown from day one.
Top comments (0)