senternet-site-prerender
Add Puppeteer prerendering for all routes and strip runtime-only markup.
SKILL.md.claude/skills/senternet-site-prerender/SKILL.md
GitHubStatic Prerendering with Puppeteer
Add Puppeteer-based static prerendering so every route produces a real HTML snapshot for SEO crawlers and social media link previews.
Why prerender
React SPAs serve a near-empty index.html to crawlers by default. Googlebot and social crawlers (Twitter, Discord, Slack) see no content. Prerendering visits each route with a headless browser and writes the rendered HTML to disk, giving crawlers full page content and correct meta tags.
Steps
1. Install Puppeteer
npm install -D puppeteer
2. Update src/main.tsx to use hydrateRoot and dispatch app-ready
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root')!;
hydrateRoot(root, <App />);
// Signal the prerender script that React has mounted
setTimeout(() => {
document.dispatchEvent(new Event('app-ready'));
}, 1000);
hydrateRoot attaches React to the existing prerendered DOM instead of replacing it — this keeps LCP from the initial HTML paint. Never switch back to createRoot without testing LCP.
3. Create scripts/prerender.mjs
import puppeteer from 'puppeteer';
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const buildDir = path.resolve(__dirname, '../build');
// Add every route that should be prerendered
const ROUTES = [
'/',
'/privacy',
'/terms',
// Add more routes as you create pages
];
const MIME = {
'.js': 'application/javascript', '.mjs': 'application/javascript',
'.css': 'text/css', '.html': 'text/html', '.png': 'image/png',
'.webp': 'image/webp', '.json': 'application/json', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.txt': 'text/plain', '.xml': 'application/xml',
};
const server = http.createServer((req, res) => {
let urlPath = req.url.split('?')[0];
if (!path.extname(urlPath)) urlPath = '/index.html';
const file = path.join(buildDir, urlPath);
const ext = path.extname(file);
try {
res.setHeader('Content-Type', MIME[ext] || 'text/plain');
res.end(fs.readFileSync(file));
} catch {
try {
res.setHeader('Content-Type', 'text/html');
res.end(fs.readFileSync(path.join(buildDir, 'index.html')));
} catch {
res.statusCode = 404; res.end('Not found');
}
}
});
await new Promise(r => server.listen(9999, r));
console.log('Prerender server started on :9999');
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
for (const route of ROUTES) {
const page = await browser.newPage();
// Flag for any conditional rendering that should be skipped during prerender
await page.evaluateOnNewDocument(() => { window.__PRERENDERING__ = true; });
await page.goto(`http://localhost:9999${route}`, { waitUntil: 'networkidle2', timeout: 15000 });
// Wait for React to render (app-ready event or 2s fallback)
await Promise.race([
page.evaluate(() => new Promise(resolve => {
if (document.querySelector('#root')?.innerHTML?.length > 100) return resolve();
document.addEventListener('app-ready', resolve, { once: true });
setTimeout(resolve, 2000);
})),
new Promise(r => setTimeout(r, 3000)),
]);
// Strip third-party scripts so they don't double-load on real user visits
let html = await page.content();
html = html
.replace(/<script[^>]*src="https:\/\/www\.googletagmanager\.com\/gtag\/js[^"]*"[^>]*><\/script>/g, '')
.replace(/<script[^>]*src="https:\/\/scripts\.clarity\.ms\/[^"]*"[^>]*><\/script>/g, '')
.replace(/<script[^>]*src="https:\/\/www\.redditstatic\.com\/ads\/pixel\.js[^"]*"[^>]*><\/script>/g, '');
const outDir = route === '/' ? buildDir : path.join(buildDir, route);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'index.html'), html);
const rootLen = await page.evaluate(() => document.getElementById('root')?.innerHTML?.length ?? 0);
console.log(`${route}: ${rootLen > 100 ? '✓' : '✗ EMPTY'} (${rootLen} chars)`);
await page.close();
}
await browser.close();
server.close();
4. Hook prerender into the build pipeline
In package.json:
{
"scripts": {
"build:prod": "tsc -b && vite build --mode production && node scripts/prerender.mjs",
"build:dev": "tsc -b && vite build --mode development && node scripts/prerender.mjs"
}
}
5. Multilingual prerendering (if the site has i18n)
For sites with /es/, /fr/ etc. routes, loop over locales as well:
const LOCALES = [
{ language: 'en', dir: '' },
{ language: 'es', dir: 'es' },
];
for (const locale of LOCALES) {
for (const route of ROUTES) {
const page = await browser.newPage();
if (locale.language !== 'en') {
await page.evaluateOnNewDocument((lang) => { window.__LANG__ = lang; }, locale.language);
}
const requestPath = locale.dir
? `/${locale.dir}${route === '/' ? '' : route}`
: route;
// ... rest of prerender logic
const outDir = locale.dir
? path.join(buildDir, locale.dir, route === '/' ? '' : route)
: (route === '/' ? buildDir : path.join(buildDir, route));
// ...
}
}
Rules
- Every route added to
src/App.tsxandpublic/sitemap.xmlmust also be added to theROUTESarray inscripts/prerender.mjs - Each page component must call
<MetaTags>so the correct title/description/OG tags appear in the prerendered snapshot - If a route renders empty (
✗ EMPTY), theMetaTagscomponent is not dispatchingapp-ready— check thatdocument.dispatchEvent(new Event('app-ready'))is called in the component'suseEffect window.__PRERENDERING__ = truecan be used to conditionally skip animations, API calls, or interactivity that breaks in headless Chrome
Framework: Next.js track
This skill does not apply to the nextjs track — skip it entirely.
Next.js renders every route it can to static HTML during next build. Running Puppeteer over that output would add build time and produce a second, divergent copy of every page that drifts from the one the framework serves. Never install Puppeteer, create scripts/prerender.mjs, add hydrateRoot, or dispatch app-ready in a Next.js repo. Finding any of those there means a Vite-track step was applied to the wrong track; flag it rather than extending it.
Check the equivalent guarantee instead:
npm run build— read the printed route table. Marketing routes must be static (○) or ISR, not dynamic (ƒ). A page that went dynamic by accident (acookies()/headers()/searchParamsread, or'use client'too high in the tree) is rendering per request and paying a cold start for content that never changes.npm start, thencurl -s localhost:$PORT/<route>for each route. The response must contain the page's real headline and its meta tags — this is exactly what a crawler that runs no JavaScript sees. An empty or shell-only response here is the same failure the Vite track reports as✗ EMPTY.- Third-party scripts do not need stripping. On the Vite track the prerender step removes GA/Reddit/Clarity tags so they don't double-load; on this track they are rendered by
next/script, which handles loading once.
See /senternet-site-framework for the full convention map.