vite-mastery

7.8 · difficulty 3/4 · 18 min read

Backend Integration: When Vite Does Not Own HTML

How to integrate Vite with Rails, Laravel, Express, Go templates, or any backend that renders HTML while Vite owns assets, HMR, and the production manifest.

Vite 8.1Stable

When You Need Backend Integration

In a standard Vite app, index.html is the entry:

text
browser -> Vite dev server -> index.html -> /src/main.ts

Traditional backends often own HTML:

text
browser -> Rails/Laravel/Express/Go -> server template
                                      -> Vite owns JS/CSS/assets

Use this model when the backend handles auth before rendering, different routes produce different templates, an existing app is being migrated, or the framework already owns HTML.

The rule: the backend owns HTML; Vite owns the frontend entry and asset graph.

Core Config: Entry and Manifest

ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
  plugins: [react()],
  server: {
    cors: {
      origin: "http://localhost:3000",
    },
  },
  build: {
    manifest: true,
    rolldownOptions: {
      input: "/absolute/path/to/src/main.tsx",
    },
  },
})
OptionPurpose
server.cors.originallow backend pages to load modules from the Vite dev server
build.manifestemit .vite/manifest.json for backend templates
build.rolldownOptions.inputbuild from JS/TS entries instead of HTML

For multiple backend templates:

ts
export default defineConfig({
  build: {
    manifest: true,
    rolldownOptions: {
      input: {
        main: "src/main.ts",
        admin: "src/admin.ts",
        checkout: "src/checkout.ts",
      },
    },
  },
})

Development: Inject Vite Scripts

Do not read the manifest in dev. Inject the dev server entry directly:

html
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.tsx"></script>

/@vite/client opens the HMR websocket, handles CSS updates, displays error overlays, and performs full reloads.

React Fast Refresh Preamble

When using @vitejs/plugin-react, Vite cannot modify backend-rendered HTML. Add the preamble before the entry:

html
<script type="module">
  import RefreshRuntime from "http://localhost:5173/@react-refresh"
  RefreshRuntime.injectIntoGlobalHook(window)
  window.$RefreshReg$ = () => {}
  window.$RefreshSig$ = () => (type) => type
  window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.tsx"></script>

Many "React HMR is broken" backend integration bugs are missing this block.

Asset URLs in Dev

If app code imports assets:

ts
import logoUrl from "./logo.svg"

The browser may need to request assets from the Vite dev server. Use either backend proxying or server.origin:

ts
export default defineConfig({
  server: {
    origin: "http://localhost:5173",
  },
})

Pick one strategy and document it. Mixing proxying and absolute origins makes asset bugs harder to debug.

Production: Read the Manifest

Build output:

text
dist/
  assets/
    main-B7PI925R.js
    main-ChJ_j-JJ.css
  .vite/
    manifest.json

Example manifest:

json
{
  "src/main.tsx": {
    "file": "assets/main-B7PI925R.js",
    "isEntry": true,
    "css": ["assets/main-ChJ_j-JJ.css"],
    "imports": ["_vendor-Df3x.js"]
  }
}

The backend should read the manifest, find the source entry, render CSS links, render modulepreload tags for imports, and then render the entry script.

ts
interface ManifestChunk {
  file: string
  css?: string[]
  imports?: string[]
  isEntry?: boolean
}

type Manifest = Record<string, ManifestChunk>

export function renderViteAssets(manifest: Manifest, entry: string, base = "/") {
  const chunk = manifest[entry]
  if (!chunk) throw new Error(`Missing Vite manifest entry: ${entry}`)

  const tags: string[] = []

  for (const cssFile of chunk.css ?? []) {
    tags.push(`<link rel="stylesheet" href="${base}${cssFile}">`)
  }

  for (const imported of chunk.imports ?? []) {
    const importedChunk = manifest[imported]
    if (importedChunk) {
      tags.push(`<link rel="modulepreload" href="${base}${importedChunk.file}">`)
    }
  }

  tags.push(`<script type="module" src="${base}${chunk.file}"></script>`)
  return tags.join("\n")
}

Real projects should recurse through imports and handle dynamic import CSS. Do not scrape dist/index.html; the manifest is the stable integration surface.

Modulepreload Polyfill

If your backend bypasses Vite-generated HTML and you have not disabled the polyfill, import it at the top of your entry:

ts
import "vite/modulepreload-polyfill"

It must run before the rest of the app.

Multi-Entry Mapping

Use explicit mappings:

ts
export const viteEntries = {
  home: "src/main.ts",
  admin: "src/admin.ts",
  checkout: "src/checkout.ts",
} as const

Avoid building paths from template names. Explicit maps are easier to audit and safer during renames.

Common Failures

SymptomCauseFix
Page loads but HMR does not workmissing @vite/clientinject the client script
React refresh loses statemissing preambleinject React Refresh preamble
Asset URLs point to backend portno proxy or server.originchoose one asset strategy
CSS missing in productionbackend renders only JSread css from manifest
Dynamic chunks 404base and static mount differalign Vite base with backend static path

Check Yourself

  1. Why should backend templates not hard-code production filenames?
  2. Why is @vite/client needed in dev?
  3. What problem does build.manifest solve?
  4. Why does React need a manual preamble here?
ts
// Implement renderViteAssets:
// - input: manifest, entry, base
// - output CSS links
// - output modulepreload tags for imports
// - output entry script
// - throw a clear error for missing entries