Deploy Django with Vite Frontend Assets

Ship Vite-built JavaScript and CSS with Django in production: manifest-based asset tags with django-vite, a Node build stage, collectstatic, cache headers, and avoiding dev-server leaks.

Problem statement

Many Django projects now use Vite to bundle JavaScript and CSS: TypeScript, component frameworks, or just modern tooling for a few interactive pages. In development, templates load assets from the Vite dev server with hot reload. In production, the dev server must not exist, assets must be built once with hashed filenames, and templates must reference those exact files.

The common failures are pages that still point at localhost:5173, a missing or mislocated manifest.json, collectstatic not picking up the build output, Node installed in the production image for no reason, and CSS or JavaScript served with short cache lifetimes. This guide sets up a clean build-and-deploy path.

Vite assets in a Django releasevite buildhashed bundlesmanifest.jsonsource to output mapcollectstaticSTATIC_ROOTTemplatesvite_asset tagsProduction templates read the manifest for hashed files; the Vite dev server is never used.
  1. Configure Vite with build.manifest, a fixed outDir, explicit rollupOptions.input, and base equal to your STATIC_URL plus any prefix.
  2. Use django-vite template tags; set its dev_mode from DEBUG so production reads the manifest.
  3. Add Vite's output directory to STATICFILES_DIRS so collectstatic includes the bundles.
  4. Build assets in CI or a Docker build stage with npm ci && npm run build; do not ship Node in the runtime image.
  5. Serve the collected files with long-lived cache headers (WhiteNoise or Nginx).
  6. Verify the rendered HTML contains no localhost:5173 and all asset URLs return 200.

Step-by-step solution

1. Vite configuration

// frontend/vite.config.js
import { defineConfig } from "vite";
import { resolve } from "node:path";

export default defineConfig({
  base: "/static/",
  build: {
    manifest: "manifest.json",
    outDir: resolve(__dirname, "dist"),
    emptyOutDir: true,
    rollupOptions: {
      input: {
        main: resolve(__dirname, "src/main.js"),
        admin_extras: resolve(__dirname, "src/admin-extras.js"),
      },
    },
  },
  server: {
    origin: "http://localhost:5173",
  },
});
the Vite settings Django depends onbase:"/static/",build: {manifest: "manifest.json",outDir: resolve(__dirname, "dist"),rollupOptions: {input: { main: … } },},12341Must equal STATIC_URL, or CSS points at missing files.2Maps entry names to hashed files for the template tag.3Listed in STATICFILES_DIRS so collectstatic copies it.4One entry per bundle the templates load.Hashed file names are what make long cache lifetimes safedjango-deployment.com
A CDN in front only changes STATIC_URL and base, together.
  • manifest: "manifest.json" writes the manifest to dist/manifest.json. Recent Vite versions put it in dist/.vite/manifest.json when manifest: true, which is a common reason Django cannot find it.
  • base: "/static/" makes Vite generate URLs (for chunks, fonts, and images referenced from CSS) under Django's STATIC_URL. If you serve static files from a CDN, base must match the final URL prefix.
  • Explicit inputs give each entry point a stable name that templates refer to.

2. Django settings

pip install django-vite
# settings/base.py
INSTALLED_APPS += ["django_vite"]

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "frontend" / "dist"]

DJANGO_VITE = {
    "default": {
        "dev_mode": DEBUG,
        "manifest_path": BASE_DIR / "frontend" / "dist" / "manifest.json",
    }
}

The configuration format has changed between django-vite major versions (older versions used flat DJANGO_VITE_* settings), so match the documentation for the version you install.

3. Template tags

{% load django_vite %}
<!doctype html>
<html>
  <head>
    {% vite_hmr_client %}
    {% vite_asset 'src/main.js' %}
  </head>
  <body>...</body>
</html>

With dev_mode on, these tags point at the Vite dev server with hot module replacement. With dev_mode off, vite_hmr_client renders nothing and vite_asset reads the manifest, emitting a <script type="module"> tag for the hashed bundle plus <link> tags for its CSS and preloads for imported chunks.

4. Build in a Docker stage

# Stage 1: build frontend assets
FROM node:22-slim AS assets
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Stage 2: Python runtime
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY --from=assets /app/frontend/dist /app/frontend/dist
RUN DJANGO_SETTINGS_MODULE=config.settings.build python manage.py collectstatic --noinput
USER nobody
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
Node builds, Python shipsnode stagenpm ci, vite builddist/ with manifest.jsonhashed JS and CSSpython stageCOPY --from=node dist/collectstaticinto STATIC_ROOTNo node_modules and no Node runtime in the final imagedjango-deployment.com
Copy package-lock.json before the sources so npm ci stays cached.

Add frontend/node_modules and frontend/dist to .dockerignore so local builds do not leak into the image. The final image contains the built files but no Node, node_modules, or source maps you did not intend to publish.

For non-Docker deploys, run npm ci && npm run build in CI, then ship frontend/dist with the release artifact, or run the build on the server before collectstatic if Node is installed there.

5. Static storage and caching

Vite already puts content hashes in filenames. With ManifestStaticFilesStorage (or WhiteNoise's compressed manifest storage), Django adds its own hash as well; that works, but Django also rewrites url() references inside CSS and fails if a referenced file is missing. If Vite's CSS references assets that Django cannot resolve, make sure they are in the build output and that base matches STATIC_URL. Alternatively, since Vite already hashes its files, use a storage without Django's manifest step (such as WhiteNoise's CompressedStaticFilesStorage), accepting that Django's own static files, such as the admin's, are then not hashed.

cache what is hashed, revalidate what is notCache-Control/static/assets/*.js, *.css✓public, max-age=31536000, immutablemanifest.json!server-side only, never publicHTML pagesno-cache or short max-ageA new build changes file names, so old caches never serve stale codedjango-deployment.com
The HTML is what points at new hashes, so it is the one thing that must not be cached long.

Serve everything under /static/ with long cache lifetimes:

location /static/ {
    alias /srv/myapp/current/staticfiles/;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

WhiteNoise sets far-future cache headers for files with hashed names automatically.

6. Guard against dev-mode leaks

Add a test that renders a page with production settings and checks for dev-server URLs:

from django.test import TestCase, override_settings


class ViteProductionTest(TestCase):
    @override_settings(DEBUG=False)
    def test_no_dev_server_urls(self):
        html = self.client.get("/").content.decode()
        self.assertNotIn("localhost:5173", html)
        self.assertNotIn("@vite/client", html)
no dev server in productionNo localhost:5173 in page sourcedev server tags offdev_mode tied to DEBUGor an explicit env flagManifest present at startfail loudly if missingSource maps policy decidedhidden or not uploadedA leaked dev-server script tag fails silently for every visitordjango-deployment.com
Add a test that renders a page with production settings and greps for 5173.

Because django-vite reads dev_mode at start-up from settings, a test that toggles DEBUG alone may not flip it; point dev_mode at an environment variable or test with a production settings module in CI.

Verification check:

curl -s https://example.com/ | grep -Eo '(src|href)="/static/[^"]+"' | head
curl -sI https://example.com/static/assets/main-3b1c9a0d.js | grep -iE "^(HTTP|cache-control|content-type)"

Expect hashed filenames, 200, application/javascript (or text/javascript), and a long Cache-Control lifetime.

Explanation

Vite's manifest is a JSON map from source entry points to built files, including the CSS and chunks each entry imports. django-vite uses it at render time to emit the correct tags, so templates refer to stable source names while browsers receive hashed, cacheable files. collectstatic then treats Vite's output like any other static directory, which keeps the production path identical to a plain Django project: one STATIC_ROOT, served by WhiteNoise, Nginx, or a CDN.

Edge cases / notes

Source maps

Production source maps help error tracking but expose source code if published. Either do not build them, or upload them to your error tracker and exclude them from the static directory.

Multiple frontends

django-vite supports several named app configurations, each with its own manifest, which is useful when the admin area and the public site are separate Vite projects.

Rollback

Because assets are part of the release (image or artifact), rolling back code rolls back assets too. Avoid building assets on the server in place, which breaks this pairing.

When to automate this

Build assets in CI once per commit and include them in the artifact that is deployed everywhere. Add the dev-server leak test and a post-deploy check that fetches the entry bundle's URL from the rendered page.

Next steps

FAQ

Why does my production page still load assets from localhost:5173?

django-vite is in dev mode. Set dev_mode to False in production settings, typically by tying it to DEBUG or an environment variable.

Where does Vite put manifest.json?

With manifest: true, recent Vite versions write outDir/.vite/manifest.json. Set manifest: "manifest.json" or point manifest_path at the actual location.

Do I need Node.js on the production server for Vite?

No. Build assets in CI or a Docker build stage and ship only the output.

Why does collectstatic fail after adding Vite?

Manifest storage rewrites url() references in CSS and raises errors for missing files. Make sure referenced assets exist in the build output and that base matches STATIC_URL.

Keep going