
Playwright SSG Tutorial: How to Make Lovable Apps Visible to Google
TL;DR: „Playwright crawls your SPA routes at build time and saves fully rendered HTML as static files. Result: Google sees everything, Lighthouse scores go up, and you don't have to switch frameworks."
— Till FreitagThe Problem: Google Can't See Your Lovable App
You've built an amazing app with Lovable. Design is on point, features work, users love it. But Google? Google sees an empty page.
That's because Lovable – like all Vibe Coding tools – generates Single Page Applications (SPAs). The server delivers an empty index.html, and JavaScript renders the content in the browser. Works for humans, not for crawlers.
Background: Our Vibe Coding SEO Guide explains the problem in detail. This article is the practical deep-dive for the Playwright solution.
What Is Playwright SSG?
SSG stands for Static Site Generation. The idea: instead of hoping Googlebot executes JavaScript, we render all pages at build time ourselves – with a real browser.
Playwright is Microsoft's browser automation framework. It launches a real Chromium browser, navigates to every route of your app, waits until everything is rendered, and saves the finished HTML.
The result:
- Every route has a complete HTML file
- Google immediately sees all content
- First Contentful Paint becomes dramatically faster
- Social previews work out of the box
Prerequisites
Before you start, you need:
- Your Lovable app on GitHub – Export code via Lovable's GitHub integration
- Node.js 18+ on your build system
- Basic command line knowledge
Not on GitHub yet? Our Lovable → GitHub → Vercel Flow Guide walks you through the entire process.
Step 1: Install Playwright
npm install -D playwright-core @playwright/test
npx playwright install chromiumYou only need Chromium – Firefox and WebKit aren't necessary for SSG.
Step 2: Create a Route List
Playwright needs to know which pages to render. You have two options:
Option A: Static Route List
// scripts/routes.ts
export const routes = [
'/',
'/about',
'/blog',
'/blog/my-first-article',
'/contact',
// ... all your routes
];Option B: Automatic Crawling
// scripts/discover-routes.ts
import { chromium } from 'playwright-core';
async function discoverRoutes(baseUrl: string): Promise<string[]> {
const browser = await chromium.launch();
const page = await browser.newPage();
const discovered = new Set<string>(['/']);
const queue = ['/'];
while (queue.length > 0) {
const route = queue.shift()!;
await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' });
const links = await page.$eval('a[href]', anchors =>
anchors
.map(a => new URL(a.href, window.location.origin).pathname)
.filter(href => href.startsWith('/') && !href.includes('.'))
);
for (const link of links) {
if (!discovered.has(link)) {
discovered.add(link);
queue.push(link);
}
}
}
await browser.close();
return [...discovered];
}We recommend Option B for websites with many dynamic routes (e.g., blog articles).
Step 3: The SSG Script
Here's the core – the script that renders all routes and saves them as HTML:
// scripts/ssg.ts
import { chromium } from 'playwright-core';
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
const DIST_DIR = './dist';
const BASE_URL = 'http://localhost:4173'; // Vite preview port
async function renderRoutes(routes: string[]) {
const browser = await chromium.launch();
const context = await browser.newContext();
for (const route of routes) {
const page = await context.newPage();
await page.goto(`${BASE_URL}${route}`, {
waitUntil: 'networkidle',
timeout: 30000,
});
// Wait for dynamic content
await page.waitForTimeout(1000);
const html = await page.content();
// Save as index.html in the route directory
const filePath = route === '/'
? join(DIST_DIR, 'index.html')
: join(DIST_DIR, route, 'index.html');
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, html, 'utf-8');
console.log(`✅ Rendered: ${route}`);
await page.close();
}
await browser.close();
console.log(`\n🎉 ${routes.length} pages rendered!`);
}Step 4: Assemble the Build Script
Add an ssg script to your package.json:
{
"scripts": {
"build": "vite build",
"preview": "vite preview",
"ssg": "npm run build && npm run preview & sleep 3 && tsx scripts/ssg.ts"
}
}What happens:
vite buildcreates the production buildvite previewstarts a local servertsx scripts/ssg.tsrenders all routes and overwrites the HTML files in thedistdirectory
Step 5: GitHub Actions CI/CD
Automate the SSG process with a GitHub Action:
# .github/workflows/ssg.yml
name: Build & SSG
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: npm run build
# Start preview server in background
- run: npm run preview &
- run: sleep 5
# Render all pages
- run: npx tsx scripts/ssg.ts
# Deploy to Vercel (or other host)
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: ./distStep 6: Verify Results
After the first SSG build, you can verify the results:
Lighthouse Score
npx lighthouse https://your-domain.com --output html --output-path ./lighthouse.htmlTypical improvements:
| Metric | Before SSG | After SSG |
|---|---|---|
| First Contentful Paint | 2.8s | 0.4s |
| Largest Contentful Paint | 3.5s | 0.8s |
| Time to Interactive | 4.2s | 1.1s |
| SEO Score | 60-70 | 95-100 |
Google Search Console
After deployment, check in Google Search Console:
- URL Inspection → Shows the rendered page
- Coverage → No more "Not indexed" errors
- Core Web Vitals → Green values
Common Problems and Solutions
Problem: Dynamic content isn't rendered
Solution: Increase the waitForTimeout or wait for specific selectors:
await page.waitForSelector('[data-loaded="true"]', { timeout: 10000 });Problem: Browser API calls fail
Solution: Ensure your preview server has access to all APIs. Alternatively: mock data for the SSG build.
Problem: Hash routing (#) instead of path routing (/)
Solution: Lovable uses React Router with path routing. If you have hash routing, switch to BrowserRouter.
Problem: Build is too slow
Solution: Parallelize rendering:
// Render 5 pages simultaneously
const CONCURRENCY = 5;
const chunks = [];
for (let i = 0; i < routes.length; i += CONCURRENCY) {
chunks.push(routes.slice(i, i + CONCURRENCY));
}
for (const chunk of chunks) {
await Promise.all(chunk.map(route => renderRoute(route)));
}What's Still Missing: Schema & Meta Tags
SSG solves the rendering problem. But for complete SEO you also need:
- JSON-LD Schema for structured data → Automate JSON-LD Schema for SPAs
- Meta tags for social previews (og:title, og:image, etc.)
- Sitemap and robots.txt
- Canonical URLs for multilingual content
Conclusion: SSG Is the Game Changer for Vibe Coding SEO
Playwright SSG is the missing bridge between Vibe Coding and search engines. You keep all the advantages of SPA architecture – interactive UI, fast development, AI-generated code – and get full SEO visibility on top.
The complete stack:
- Lovable → Generates the app
- GitHub → Versions the code
- Playwright → Renders static HTML (this article)
- Vercel → Delivers via Edge CDN
This is exactly the stack we used to bring till-freitag.com to Lighthouse 100.
Don't want to set this up yourself? We'll do it for you →
Our SEO audit shows you within 48 hours where your Vibe Coding app stands – and what's needed to make it visible.
Related Articles
- 📸 OG-Image Best Practices for SPAs – Pre-rendering alone isn't enough: how to create perfect social previews for LinkedIn, Twitter, and WhatsApp.
- 📊 Google Search Console for Vibe Coding Projects – How to verify after SSG setup that Google is actually indexing your pages.







