How I Added Pre-Rendering to a Vite Multi-Page App Without SSR
Bridging the Crawler Gap: Lightweight Pre-Rendering for Vite Multi-Page Applications
Current Situation Analysis
Modern frontend tooling has optimized heavily for developer experience and runtime performance, but this optimization frequently creates a blind spot for web crawlers. When you build a Multi-Page Application (MPA) using Vite on a static host like Cloudflare Pages, the default output is a collection of thin HTML shells. The actual content, headings, and descriptive text are injected at runtime via client-side JavaScript. For human visitors, this is seamless. For search engines and social media scrapers, it is invisible.
This problem is routinely misunderstood because developers assume crawlers execute JavaScript identically to modern browsers. They do not. Google operates a two-pass indexing system: the first pass parses the raw HTML, queues the page for JavaScript execution, and indexes it later. The second pass runs after resources are fetched and scripts are evaluated. This delay consumes crawl budget and slows down the indexing of new or updated pages. Other major crawlers, including Ahrefs, Facebook's scraper, and X's link preview bot, often skip JavaScript execution entirely or timeout before the DOM is populated.
The scale of the issue becomes apparent in production environments. A site with 1,449 tool pages across 25 languages, all rendering content client-side, will trigger immediate SEO degradation. Audit tools flag missing H1 tags, low word counts, and absent meta descriptions. Social sharing previews break. New pages sit in indexing limbo. The standard remedies are structurally incompatible with static hosting: Server-Side Rendering requires a runtime backend, Static Site Generation demands a framework migration (e.g., Astro, Vike, or Nuxt), and headless browser pre-rendering (Puppeteer/Playwright) introduces heavy dependencies and multi-minute build penalties.
The industry needs a middle ground that preserves the lightweight nature of static hosting while delivering crawler-readable HTML at build time.
WOW Moment: Key Findings
The breakthrough comes from decoupling SEO content from interactive UI logic. By injecting only the metadata and static text during the build phase, you eliminate the crawler blind spot without sacrificing runtime performance or infrastructure simplicity.
| Approach | Build Duration | Crawler Visibility | Infrastructure Overhead | Maintenance Complexity |
|---|---|---|---|---|
| Client-Side Only | ~15s | 0% (Empty DOM) | None | Low |
| Headless Browser Pre-render | ~180s | 100% | High (Browser binaries, memory) | Medium |
| Hybrid Build-Time Injection | ~23s | 100% | None | Low |
This finding matters because it proves that SEO compliance does not require framework lock-in or backend provisioning. A build-time injection strategy adds approximately 3 seconds to a standard Vite build while delivering full crawler visibility across all language variants. It enables immediate social preview generation, eliminates two-pass indexing delays, and keeps the interactive application layer entirely client-side. The architecture remains framework-agnostic, deployable to any static CDN, and fully compatible with existing i18n pipelines.
Core Solution
The implementation relies on a custom Vite plugin that intercepts the build output, reads localized content dictionaries, and injects SEO-critical nodes directly into the generated HTML files. The interactive tool interface remains untouched, preserving client-side performance and avoiding hydration mismatches.
Step 1: Structure the Content Registry
Centralize all SEO-relevant text in a machine-readable format. Instead of scattering strings across components, maintain a single registry that maps locales to tool-specific metadata.
// src/content-registry.ts
export const contentRegistry: Record<string, Record<string, any>> = {
en: {
compress: {
h1: "Compress PDF Files Online",
metaDesc: "Reduce PDF file size without losing quality. Fast, secure, and free.",
faqs: [
{ q: "Is my data safe?", a: "Files are processed locally and deleted immediately." },
{ q: "What is the max file size?", a: "Up to 50MB per upload." }
]
},
resize: { /* ... */ }
},
fr: {
compress: {
h1: "Compresser des fichiers PDF en ligne",
metaDesc: "Réduisez la taille de vos PDF sans perte de qualité.",
faqs: [ /* ... */ ]
}
}
};
Step 2: Build the Injection Plugin
Create a Vite plugin that targets the closeBundle hook. This ensures all assets are emitted and hashed before injection occurs. The plugin iterates through locales and modules, reads the corresponding HTML output, and replaces placeholder markers with the registry data.
// vite-plugins/seo-injector.ts
import { Plugin } from 'vite';
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
import { join, resolve } from 'path';
import { contentRegistry } from '../src/content-registry';
function resolveDistPath(distDir: string, locale: string, moduleSlug: string): string {
return join(distDir, locale, moduleSlug, 'index.html');
}
function generateFaqMarkup(faqs: Array<{ q: string; a: string }>): string {
return faqs
.map(f => `<details><summary>${f.q}</summary><p>${f.a}</p></details>`)
.join('\n');
}
export function seoInjectorPlugin(distDir: string): Plugin {
const locales = Object.keys(contentRegistry);
const modules = Object.keys(contentRegistry[locales[0]]);
return {
name: 'vite-seo-injector',
apply: 'build',
closeBundle() {
console.log(`[SEO Injector] Processing ${locales.length} locales across ${modules.length} modules...`);
for (const locale of locales) {
for (const moduleSlug of modules) {
const targetPath = resolveDistPath(distDir, locale, moduleSlug);
try {
const rawHtml = readFileSync(targetPath, 'utf-8');
const data = contentRegistry[locale][moduleSlug];
if (!data) continue;
const injectedHtml = rawHtml
.replace('<!-- SEO_H1 -->', `<h1>${data.h1}</h1>`)
.replace('<!-- SEO_META_DESC -->', `<meta name="description" content="${data.metaDesc}">`)
.replace('<!-- SEO_FAQS -->', generateFaqMarkup(data.faqs))
.replace(/<html lang="en">/, `<html lang="${locale}">`);
writeFileSync(targetPath, injectedHtml, 'utf-8');
} catch (err) {
console.warn(`[SEO Injector] Skipped ${targetPath}: ${err}`);
}
}
}
console.log('[SEO Injector] Build-time injection complete.');
}
};
}
Step 3: Prepare HTML Templates
Each page template must contain explicit injection markers. These markers are stripped during client-side hydration but remain visible to crawlers.
<!-- dist/en/compress/index.html (simplified) -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- SEO_META_DESC -->
<title>PDF Compressor</title>
</head>
<body>
<header>
<!-- SEO_H1 -->
</header>
<main id="app">
<!-- Interactive UI mounts here -->
</main>
<section id="faq-container">
<!-- SEO_FAQS -->
</section>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Architecture Decisions & Rationale
- Why
closeBundle? This hook fires after Vite finishes emitting all assets and generating hashes. Injecting earlier (e.g.,generateBundle) risks overwriting hashed filenames or breaking asset references.closeBundleguarantees a stable output directory. - Why i18n-driven injection? Centralizing content in a registry eliminates duplication. The same dictionary powers both the client-side UI and the build-time HTML, ensuring consistency across crawlers and users.
- Why preserve client-side UI? Interactive components (drag-and-drop, canvas manipulation, file processing) require runtime state. Pre-rendering them causes hydration mismatches and increases bundle size. The hybrid approach isolates static SEO content from dynamic application logic.
- Why marker-based replacement? Regex/string replacement is dependency-free and significantly faster than parsing HTML with DOM libraries. For 1,449 pages, this approach adds ~3 seconds to the build, compared to minutes with headless browsers.
Pitfall Guide
1. Locale Metadata Mismatch
Explanation: Hardcoding English <title> or <meta> tags in base templates causes crawlers to index French or Spanish URLs with English metadata. This triggers duplicate content warnings and harms regional SEO.
Fix: Extend the injection plugin to dynamically replace all <meta> and <title> tags using the locale-specific registry. Never rely on static template defaults for multi-language deployments.
2. Flash of Unhydrated Content (FOUC)
Explanation: When the client-side router initializes, it may wipe the pre-rendered DOM before hydration completes. Users briefly see static text, then a blank screen, then the interactive UI.
Fix: Defer JavaScript execution until after the hydration check. Use requestAnimationFrame or a visibility observer to ensure the interactive UI only replaces the placeholder after the DOM is stable. Alternatively, scope the client router to mount only inside #app, leaving SEO nodes untouched.
3. Bot Protection False Positives
Explanation: CDNs like Cloudflare often enable Browser Integrity Checks by default. These challenges block social media scrapers (Facebook, X, LinkedIn) because they lack JavaScript execution capabilities, returning 403 errors instead of preview cards.
Fix: Configure the CDN to whitelist known crawler user-agents. Disable JavaScript challenges for paths matching /en/*, /fr/*, etc., or create a page rule that bypasses integrity checks for SEO-critical routes.
4. Over-Injecting Interactive Elements
Explanation: Attempting to pre-render complex UI components (forms, canvases, state-driven lists) leads to hydration mismatches. React/Vue/Svelte expects the server HTML to match the virtual DOM exactly.
Fix: Strictly separate concerns. Pre-render only semantic, static content (headings, descriptions, FAQs, internal links). Keep all stateful or event-driven components inside dedicated mount points (#app, #tool-ui).
5. Build Hook Timing Errors
Explanation: Running injection logic during configResolved or buildStart processes files before they are generated. The plugin will fail or inject into stale templates.
Fix: Always use closeBundle or writeBundle. Verify that the output directory exists and contains the expected locale/module structure before attempting file I/O. Add graceful error handling for missing routes.
6. Ignoring Dynamic Route Parameters
Explanation: Pre-rendering generates static HTML, but some pages rely on query parameters (e.g., ?tool=compress&mode=high). Crawlers may index the base URL, missing parameter-specific variations.
Fix: Keep parameter-dependent content client-side. Pre-render only the canonical version of each route. Use <link rel="canonical"> to prevent duplicate indexing, and ensure the client router handles parameter state without breaking the pre-rendered shell.
Production Bundle
Action Checklist
- Audit existing templates for hardcoded English metadata and replace with injection markers
- Centralize all SEO text, FAQs, and descriptions into a single i18n registry
- Implement the
closeBundleVite plugin with locale/module iteration logic - Verify HTML output using
view-sourceto confirm crawler-visible content - Test social sharing previews using Facebook Debugger and X Card Validator
- Configure CDN to bypass JavaScript challenges for known crawler user-agents
- Run a full crawl simulation (Screaming Frog or Ahrefs) to validate H1 and meta coverage
- Monitor build duration to ensure injection adds <5 seconds to CI/CD pipeline
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Static-hosted MPA with 50+ pages | Hybrid Build-Time Injection | Zero backend cost, preserves static CDN performance, minimal build overhead | None (infrastructure) |
| Real-time data-driven pages | Client-Side Rendering + Sitemap | Pre-rendering cannot handle live data; rely on dynamic rendering + fast indexing | Low (CDN egress) |
| Enterprise SEO with 10k+ pages | Headless Browser Pre-render | Guarantees full DOM parity for complex SPAs, but requires CI resources | Medium (build minutes) |
| Framework migration planned | Static Site Generation (Astro/Vike) | Native SSG eliminates need for custom plugins; better long-term maintainability | High (dev time) |
Configuration Template
// vite.config.ts
import { defineConfig } from 'vite';
import { seoInjectorPlugin } from './vite-plugins/seo-injector';
export default defineConfig({
build: {
outDir: 'dist',
rollupOptions: {
input: {
main: 'index.html',
compress: 'src/pages/compress.html',
resize: 'src/pages/resize.html',
merge: 'src/pages/merge.html'
}
}
},
plugins: [
seoInjectorPlugin('dist')
]
});
Quick Start Guide
- Create the content registry: Extract all H1 tags, meta descriptions, and FAQs from your components into a structured JSON/TS file keyed by locale and module.
- Add injection markers: Insert
<!-- SEO_H1 -->,<!-- SEO_META_DESC -->, and<!-- SEO_FAQS -->into your base HTML templates at the exact DOM locations where crawlers expect them. - Register the plugin: Import
seoInjectorPluginintovite.config.ts, pass your output directory, and runnpm run build. - Verify output: Open
dist/en/compress/index.htmlin a text editor. Confirm the markers are replaced with localized content and the<html lang>attribute matches the locale. - Deploy and test: Push to your static host. Run a URL inspection tool (Google Search Console, Facebook Debugger) to confirm first-pass indexing and social preview generation.
