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

Published: (May 10, 2026 at 08:20 PM EDT)
4 min read
Source: Dev.to

Source: Dev.to

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

I run RelahConvert, an online file conversion site with 50+ tools across 25 languages. Last week I noticed a problem in my Ahrefs audit: every single one of my 1,449 pages was flagged as “H1 missing” and “Low word count.”

The pages weren’t empty. They had H1s, descriptions, FAQs — all rendering correctly when you visited them. But crawlers saw an empty body.

The Setup

The site is a Vite MPA (multi‑page app), not a SPA. Each tool has its own .html file. Tool‑specific JavaScript injects the page content at runtime via:

document.querySelector('#app').innerHTML = template;

This works great for users: the page loads, JS runs, content appears. However, crawlers that don’t execute JavaScript (Ahrefs, basic bots, social media scrapers like Facebook and X) see an empty body. Even Google, which does run JS, incurs a delayed second‑pass rendering that costs crawl budget and slows indexing of new pages.

The Usual Solutions Didn’t Fit

SolutionWhy it didn’t work
Server‑Side Rendering (SSR)Requires a runtime backend; I’m on Cloudflare Pages (static hosting).
Static Site Generation (SSG)Would need a full restructure around a framework like Astro or Vike—massive refactor for a site already shipping.
Pre‑rendering with PuppeteerSpinning up a headless browser for every route adds ~3‑5 minutes to the build and pulls in heavy dependencies.

I wanted something lighter: just inject the SEO‑relevant content (H1, description, FAQs) into the static HTML at build time, without re‑implementing the entire UI in Node.

The Approach: Hybrid Pre‑Render via Vite Plugin

I extended the existing build plugin to read my i18n dictionaries and write the SEO content directly into each HTML file. The interactive tool UI (drag‑drop, canvas operations, conversion logic) stays JS‑rendered — only the content that matters for crawlers gets baked in.

Rough shape of the plugin

function preRenderPlugin() {
  return {
    name: 'pre-render-seo',
    apply: 'build',
    closeBundle() {
      const tools = ['compress', 'resize', 'merge-pdf', /* … */];
      const langs = ['en', 'fr', 'es', 'de', 'ar', /* … */];

      for (const tool of tools) {
        for (const lang of langs) {
          const i18n = loadI18n(lang);
          const seo = i18n.seo[tool];

          const html = readHTML(`dist/${lang}/${slugFor(tool, lang)}/index.html`);
          const injected = injectSEOContent(html, {
            h1: i18n.nav_short[tool],
            description: i18n.heroDesc,
            body: seo.body,
            faqs: seo.faqs,
          });

          // The content gets injected into the placeholder.
          // When JS runs at runtime, it replaces the contents with the interactive UI —
          // but the pre‑rendered version is what crawlers see.
          writeHTML(path, injected);
        }
      }
    },
  };
}

Three Gotchas

  1. Per‑language metadata – My tool pages had hard‑coded English <title> and <meta> tags, even on French URLs. Pre‑rendering exposed the mismatch, so I extended the plugin to resolve title and meta description per language from i18n at build time.

  2. FOUC on per‑language URLs – Initially I derived per‑language URLs from the homepage template. JS at runtime detected the route, wiped the body, and injected the tool, causing a brief flash of French content before it disappeared. The fix was to use each tool’s own template as the base for its language URLs.

  3. Cloudflare bot protection blocking social scrapers – After fixing OG tags, Facebook’s Sharing Debugger returned 403. Cloudflare’s Browser Integrity Check was challenging the Facebook scraper. The solution was a simple Cloudflare dashboard configuration, not a code change.

Result

  • Build time went from 22.7 s to ~23 s – negligible overhead.
  • Every tool page now ships with proper H1, description, FAQs, and internal links in the static HTML across all 25 languages.
  • Social previews work.
  • Ahrefs and Google can read content on the first crawl.

You can see the live result on the image compressor – view‑source on the page shows the pre‑rendered content.

The Takeaway

If you’re on Vite without a framework and need crawler‑friendly HTML without going full SSG, a build‑time plugin that injects SEO content into your existing structure is a surprisingly clean middle ground.

0 views
Back to Blog

Related posts

Read more »