Skip to content

Assets and Deployment

How Vite serves the front end in development and production, what the manifest is for, why asset versions force a reload, and what to change before shipping.

VITE_DEV=true. The shell points at the Vite dev server, and you get hot module replacement. Nothing is built.

VITE_DEV=false. The shell reads the build manifest and emits the hashed filenames from it. npm run build must have run.

Terminal window
npm run build # writes static/build
VITE_DEV=false uvicorn app:app

That is the whole difference. {{ inertia_head }} in root.html expands to one thing or the other.

Vite writes a manifest naming every hashed output file, and the adapter reads it to build the script tags. Without it a production render has no way to find the filenames. The page comes back with no script tag and no error.

vite.config.ts
build: {
outDir: 'static/build',
manifest: true, // without this there is no manifest
rollupOptions: { input: 'js/main.tsx' },
}
app/inertia.py
BUILD_DIR = BASE_DIR / "static" / "build"
MANIFEST = BUILD_DIR / ".vite" / "manifest.json"

The adapter is given the full path to the manifest rather than a directory. Vite moved it into .vite/ inside the output directory as of Vite 5 (it used to sit at the root) so naming the file means a future move fails loudly here instead of rendering a page with no script tag.

app/bootstrap.py mounts static/build/assets at /assets, because that is what the adapter builds URLs against: it takes each file named in the manifest, strips the leading assets/, and prefixes asset_prefix.

The directory served must be the assets folder inside the build output, not the output itself. Mount one level up and every script 404s against a manifest that is perfectly correct.

The mount is skipped when nothing is built, so pytest and sillo dev do not require npm run build first.

In production, put nginx or Caddy in front and let it serve static/build/assets directly. This mount never sees traffic.

When the client’s X-Inertia-Version does not match the current one, the middleware answers with a 409 and X-Inertia-Location before the handler runs. The client does a full visit and comes back on the current build.

That is what stops a browser running yesterday’s JavaScript from rendering a new page object against components that no longer match it.

Inertia(app, root_view=..., version="1.0.0") # a fixed string
Inertia(app, root_view=..., version=read_manifest_hash) # re-read per ctx

A version of None disables the check.

Set ASSET_VERSION to something that changes per release. A commit SHA does. Leave it fixed across deploys and clients keep the stale bundle until they happen to hard-reload.

Everything on Deployment applies. Three things are specific here:

  1. Build the front end as part of the release, before the application starts. npm run build.
  2. Set VITE_DEV=false. Left true, production serves script tags pointing at a dev server that is not running.
  3. Set ASSET_VERSION per release, so clients pick up the new bundle.

A release looks like:

Terminal window
uv sync --no-dev
npm ci && npm run build
uv run sillo db:migrate
VITE_DEV=false ASSET_VERSION=$(git rev-parse --short HEAD) uvicorn app:app

A page renders with no JavaScript and no error. The entry in vite.config.ts and ENTRY in app/inertia.py have drifted. Development still works, because the dev server serves whatever path it is asked for; production silently ships a page with no script tag.

Every script 404s but the manifest looks right. The static mount is one level too high. It must be static/build/assets, not static/build.

Styles missing in production only. Tailwind is a Vite plugin here, so the stylesheet is an output of the build like any other. If npm run build did not run, there is no CSS to name.

A client stuck on an old bundle. ASSET_VERSION did not change between releases, so the version check never fires.