JavaScript Advanced

Modern Front-End Builds for Django: Vite, Asset Hashing, Import Maps, and CSP-Safe Bundles

Wire Vite into Django without adopting a JavaScript framework. Hot reload in development, hashed bundles in production, manifest-driven templates, and no inline scripts to break your CSP.

DjangoZen Team Sep 05, 2026 18 min read 12 views

You do not need React to justify a build step. Modern CSS, TypeScript, and any package from npm all want compiling — and Django's staticfiles pipeline was designed for a world of hand-written files. The goal here is a build that improves your assets without turning your project into a JavaScript application.

What a build step actually buys you

  • One request instead of thirty. Bundling and tree-shaking remove code nobody uses.
  • Content hashing. app.9f2c4d.js can be cached forever, and changes name when it changes.
  • Modern syntax everywhere. Write current JavaScript and CSS; the build handles older browsers.
  • Real dependencies. A date library from npm instead of a copied minified file nobody dares update.

What it costs: a second process in development, and a build step in CI. That is the honest trade — worth it above roughly a few hundred lines of front-end code, not worth it below.

Project layout that stays readable

project/
  frontend/
    src/
      main.js            # entry point
      styles/app.css
      components/
    vite.config.js
    package.json
  static/                # hand-managed files: favicons, robots.txt
  static_build/          # Vite output — gitignored
  templates/

Keeping the build output in its own directory is the detail that prevents pain later: collectstatic gathers both, but you never have to wonder which files a human wrote.

Configuring Vite for Django

import { defineConfig } from "vite";

export default defineConfig({
  root: "./frontend/src",
  base: "/static/build/",
  build: {
    outDir: "../../static_build",
    emptyOutDir: true,
    manifest: true,                 // the file Django reads
    rollupOptions: { input: { main: "./frontend/src/main.js" } },
  },
  server: { port: 5173, origin: "http://localhost:5173" },
});

Two settings carry the whole integration. base must match the URL Django serves the files from, or every asset reference in the CSS points at nothing. manifest: true writes a JSON map from source name to hashed filename — that is how templates find main.9f2c4d.js without guessing.

A template tag that reads the manifest

import json
from functools import lru_cache
from django import template
from django.conf import settings
from django.utils.safestring import mark_safe

register = template.Library()


@lru_cache(maxsize=1)
def _manifest():
    path = settings.BASE_DIR / "static_build" / ".vite" / "manifest.json"
    with open(path) as fh:
        return json.load(fh)


@register.simple_tag
def vite_asset(entry="main.js"):
    if settings.DEBUG and settings.VITE_DEV_SERVER:
        return mark_safe(
            f''
            f''
        )

    item = _manifest()[entry]
    tags = [f''
            for css in item.get("css", [])]
    tags.append(f'')
    return mark_safe("".join(tags))
<head>
  {% vite_asset "main.js" %}
</head>

In development you get the dev server with hot reload; in production, hashed files from the manifest. The template never changes. lru_cache reads the manifest once per process — remember to restart workers on deploy, which you already do.

Staying CSP-clean

A strict Content Security Policy forbids inline scripts, which is exactly where template-rendered JavaScript likes to live. Two habits keep the build compatible:

Pass data through the DOM, not through generated code.

<!-- not this -->
<script>const USER_ID = {{ user.id }};</script>

<!-- this -->
<div id="app-config"
     data-user-id="{{ user.id }}"
     data-api-url="{% url 'api:root' %}"></div>
const cfg = document.getElementById("app-config").dataset;

For structured data, use json_script, which escapes correctly and produces a non-executable block:

{{ chart_data|json_script:"chart-data" }}
const data = JSON.parse(document.getElementById("chart-data").textContent);

Never inline the bundle. Vite may inline very small assets as data URIs; if your CSP forbids that, set build.assetsInlineLimit: 0. A policy that needs unsafe-inline to work is not a policy.

Import maps: the no-build option

If your JavaScript is three files and a date library, a bundler may be more machinery than the problem deserves. Import maps let the browser resolve bare specifiers directly:

<script type="importmap">
{
  "imports": {
    "htmx.org": "/static/vendor/htmx-2.0.4.min.js",
    "chart.js": "/static/vendor/chart-4.4.7.esm.js"
  }
}
</script>
<script type="module" src="{% static 'js/main.js' %}"></script>

No build, no npm in CI, works in every current browser. The trade-off is real: no tree-shaking, no TypeScript, no CSS processing, and each module is its own request. Excellent for a server-rendered Django site with light interactivity; wrong for an application with a real component tree.

Note that an import map is itself an inline script, so a strict CSP needs a nonce on it — the same nonce mechanism you already use.

Building in CI, and the layer-cache trick

FROM node:22-slim AS frontend
WORKDIR /build
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci                 # cached unless dependencies change
COPY frontend ./frontend
RUN cd frontend && npm run build

FROM python:3.13-slim
COPY --from=frontend /build/static_build /app/static_build
COPY . /app
RUN python manage.py collectstatic --noinput

Copying package*.json before the source is the whole point: your dependency install stays cached across every commit that does not change dependencies, which is most of them.

Use npm ci, never npm install, in CI — it installs exactly the lockfile and fails if the lockfile is out of date, instead of silently resolving to something newer than you tested.

Five things that will bite you

  • The manifest path moved. Vite 5 writes to .vite/manifest.json, not the root. A missing manifest at boot is the most common first failure.
  • CORS in development. The dev server is a different origin. Set server.origin, or fonts and workers fail while everything else works.
  • Two sources of truth for CSS. Pick one — the build, or Django's static files. Loading both means an override war nobody wins.
  • ManifestStaticFilesStorage hashing already-hashed files. Harmless but confusing; keep the build output in its own directory so the two hash layers stay separable.
  • Committing static_build/. Merge conflicts in minified bundles are an unwinnable fight. Gitignore it and build in CI.

Choosing honestly

  • Under ~200 lines of JS, no npm packages → plain static files. Do nothing.
  • A few libraries, server-rendered pages, HTMX → import maps with vendored files.
  • TypeScript, CSS processing, or real component code → Vite with the manifest tag above.

The failure mode worth avoiding is adopting a full front-end toolchain for a site that renders HTML on the server. You inherit the maintenance and get none of the benefit — and every dependency you add is one you will be asked to upgrade in two years.