Googlebot sees a blank div and moves on. Here is the full picture of why CSR breaks AdSense approval and the three ways to fix it without rewriting everything.
If you built your site with React and Vite using the default client-side rendering setup, there is a decent chance AdSense will reject it with something vague like "site does not have enough content" or "unable to review your site." The site looks fine to you. It loads fast, has real content, passes every visual check. What is Google seeing that you are not?
When Googlebot crawls your site, it does not always run JavaScript. The crawler has a rendering queue for JS, but it is separate from the main crawl and there can be delays of days or weeks. When AdSense reviewers check your site, they often look at the served HTML, not the rendered output. And the served HTML for a Vite SPA is basically:
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div id="root"></div>
<script type="module" src="/assets/index-abc123.js"></script>
</body>
</html>That is it. From Google's perspective, your site has no content. No headings, no paragraphs, no text to evaluate for relevance or quality. The AdSense review system sees a blank page and cannot approve it.
Fix 1: Prerendering with Playwright
The fastest fix without changing your architecture is prerendering. You write a script that spins up a headless browser, visits each page of your site, and saves the fully rendered HTML as a static file. Your server serves those static files for bots and humans alike.
import { chromium } from "playwright";
import { writeFileSync, mkdirSync } from "fs";
const routes = ["/", "/blog", "/about", "/legal/privacy"];
const browser = await chromium.launch();
const page = await browser.newPage();
for (const route of routes) {
await page.goto(`http://localhost:5173${route}`);
await page.waitForLoadState("networkidle");
const html = await page.content();
const dir = `./dist${route}`;
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/index.html`, html);
}
await browser.close();Run this after your Vite build and serve the dist folder. Every page now has real HTML content that any crawler can read.
Fix 2: SSR or SSG with a framework
If you are starting fresh or willing to migrate, moving to a framework that supports server-side rendering like Next.js, Nuxt, or Astro eliminates the problem entirely. Every page is rendered on the server and served as real HTML.
The tradeoff is complexity. SSR adds a server process that needs to stay running. SSG is simpler but requires rebuilding the static files when content changes.
Fix 3: Dynamic rendering
Serve pre-rendered HTML to known crawlers and the regular SPA to everyone else. You detect the user agent on your server and route accordingly. This is the same pattern some sites use for social media card previews.
map $http_user_agent $prerender {
default 0;
"~Googlebot" 1;
"~AdsBot-Google" 1;
"~Twitterbot" 1;
}
server {
location / {
if ($prerender = 1) {
proxy_pass http://prerender_service;
}
try_files $uri /index.html;
}
}We went with the Playwright prerender approach on our properties because it required the least structural change and works well with our Vite setup. It runs as part of the build pipeline and the output is just static files, so there is nothing extra to maintain.
One gotcha: if you are using React Router or TanStack Router, make sure your prerender script visits every route you care about explicitly. The crawler will not discover routes by crawling links in a static file that contains JS. You need to enumerate them manually or generate the list from your router config.