← Back to Blog
DevOps2026-05-11·72 min read

How I Added Pre-Rendering to a Vite Multi-Page App Without SSR

By Bright Agbomado

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. closeBundle guarantees 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 closeBundle Vite plugin with locale/module iteration logic
  • Verify HTML output using view-source to 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

  1. 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.
  2. 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.
  3. Register the plugin: Import seoInjectorPlugin into vite.config.ts, pass your output directory, and run npm run build.
  4. Verify output: Open dist/en/compress/index.html in a text editor. Confirm the markers are replaced with localized content and the <html lang> attribute matches the locale.
  5. 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.