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.

KC Ramo · DjangoZen Team Sep 05, 2026 20 min read 102 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.

Wiring the build directory into staticfiles

Vite writes files; Django still has to know they exist. The cleanest connection is a prefixed entry in STATICFILES_DIRS. The prefix makes the files in static_build/ appear under build/ in the static namespace, which is exactly the URL path that Vite's base setting points at:

# settings.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"          # collectstatic target, never edited by hand

STATICFILES_DIRS = [
    BASE_DIR / "static",                        # hand-managed files
    ("build", BASE_DIR / "static_build"),       # Vite output, served at /static/build/
]

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

VITE_DEV_SERVER = os.environ.get("VITE_DEV_SERVER") == "1"

The three names now line up: the directory on disk (static_build/), the static namespace (build/), and the public URL (/static/build/). If any one of them drifts, the symptom is the same — a page that loads its HTML and nothing else — so it is worth writing them down in one place, as above.

Note the storage backend. Vite has already content-hashed everything it emits, so plain StaticFilesStorage is enough for the build output. If you also want hashed names for the hand-managed files in static/, switch to ManifestStaticFilesStorage; it will add a second hash to Vite's files, which is harmless but means the URLs in Vite's manifest no longer match what Django serves unless you resolve them through static(). The hardened template tag below does exactly that, so both setups work.

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.

What the manifest actually contains

The manifest is worth reading once, because the template tag's correctness depends on understanding its shape. For a project with two entries and a shared dependency, it looks roughly like this:

{
  "main.js": {
    "file": "assets/main-C3vN8q1d.js",
    "name": "main",
    "src": "main.js",
    "isEntry": true,
    "imports": ["_vendor-Bf82kLx0.js"],
    "dynamicImports": ["charts/revenue.js"],
    "css": ["assets/main-D9aQe4Tz.css"]
  },
  "admin.ts": {
    "file": "assets/admin-Ak2PmW7c.js",
    "name": "admin",
    "src": "admin.ts",
    "isEntry": true,
    "imports": ["_vendor-Bf82kLx0.js"]
  },
  "_vendor-Bf82kLx0.js": {
    "file": "assets/vendor-Bf82kLx0.js",
    "css": ["assets/vendor-Xy81LmQa.css"]
  },
  "charts/revenue.js": {
    "file": "assets/revenue-R0x5sLp2.js",
    "src": "charts/revenue.js",
    "isDynamicEntry": true
  }
}

Three details matter for Django. First, keys are paths relative to root, which is why the tag looks up "main.js" and not "src/main.js". Second, imports lists static dependencies: chunks the entry will fetch as soon as it runs. The browser only discovers them after downloading and parsing the entry, so emitting a <link rel="modulepreload"> for each one removes a network round trip from the critical path. Third, CSS can live on shared chunks, not just on the entry; if you only emit the entry's own css list, a stylesheet imported by shared code will be missing on first paint and only appear once the JavaScript runs. The simple tag above handles the common single-entry case; the production version later in this tutorial walks the whole import graph.

dynamicImports are deliberately not preloaded. Those are the chunks behind import("./charts/revenue.js"), and the entire point of a dynamic import is to fetch code only on pages that need it.

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'<script type="module" src="http://localhost:5173/@vite/client"></script>'
            f'<script type="module" src="http://localhost:5173/{entry}"></script>'
        )

    item = _manifest()[entry]
    tags = [f'<link rel="stylesheet" href="/static/build/{css}">'
            for css in item.get("css", [])]
    tags.append(f'<script type="module" src="/static/build/{item["file"]}"></script>')
    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.

A production-grade version of the tag

The tag above is deliberately short so the idea is visible. Before it carries production traffic it needs four more properties: it should walk the import graph (preloads and shared-chunk CSS), build URLs through static() so a CDN or ManifestStaticFilesStorage keeps working, escape everything it interpolates, and fail with a message that tells the on-call engineer what to do. Here is a version that does all four:

# frontend/templatetags/vite.py
import json
from functools import lru_cache
from pathlib import Path

from django import template
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.safestring import mark_safe

register = template.Library()

MANIFEST_PATH = Path(settings.BASE_DIR) / "static_build" / ".vite" / "manifest.json"
DEV_ORIGIN = "http://localhost:5173"
BASE = "/static/build/"          # must equal `base` in vite.config.js


@lru_cache(maxsize=1)
def load_manifest():
    try:
        return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise ImproperlyConfigured(
            f"Vite manifest not found at {MANIFEST_PATH}. "
            "Run `npm run build` in frontend/ or set VITE_DEV_SERVER=1."
        ) from exc


def _walk(manifest, key, seen, scripts, styles):
    """Depth-first walk over static imports; dependencies before dependants."""
    if key in seen:
        return
    seen.add(key)
    chunk = manifest[key]
    for dep in chunk.get("imports", []):
        _walk(manifest, dep, seen, scripts, styles)
    for css in chunk.get("css", []):
        if css not in styles:
            styles.append(css)
    scripts.append(chunk["file"])


@register.simple_tag(takes_context=True)
def vite_entry(context, entry):
    request = context.get("request")
    nonce = getattr(request, "csp_nonce", None)
    nonce_attr = format_html(' nonce="{}"', nonce) if nonce else ""

    if settings.DEBUG and settings.VITE_DEV_SERVER:
        return format_html(
            '<script type="module" src="{0}{1}@vite/client"{3}></script>\n'
            '<script type="module" src="{0}{1}{2}"{3}></script>',
            DEV_ORIGIN, BASE, entry, nonce_attr,
        )

    manifest = load_manifest()
    if entry not in manifest or not manifest[entry].get("isEntry"):
        raise ImproperlyConfigured(
            f"{entry!r} is not an entry point in the Vite manifest; "
            "add it to build.rollupOptions.input."
        )

    scripts, styles = [], []
    _walk(manifest, entry, set(), scripts, styles)
    *preloads, entry_file = scripts

    parts = [
        format_html('<link rel="stylesheet" href="{}">', static(f"build/{css}"))
        for css in styles
    ]
    parts += [
        format_html('<link rel="modulepreload" href="{}">', static(f"build/{f}"))
        for f in preloads
    ]
    parts.append(format_html(
        '<script type="module" src="{}"{}></script>',
        static(f"build/{entry_file}"), nonce_attr,
    ))
    return mark_safe("\n".join(parts))
{% load vite %}
<head>
  {% vite_entry "main.js" %}
</head>
<!-- on the admin dashboard only -->
{% vite_entry "admin.ts" %}

Some notes on the decisions in that code:

  • The dev-server URLs include base. When base is /static/build/, the dev server serves the HMR client at /static/build/@vite/client and your modules under the same prefix. Keeping DEV_ORIGIN and BASE as constants means that if you change one in vite.config.js, there is exactly one Python line to change with it.
  • The nonce is optional. External module scripts from your own origin are allowed by script-src 'self' and do not need a nonce. Adding one anyway costs nothing and makes the tag work under a 'strict-dynamic' policy, where host-based allowlists are ignored and only nonced scripts (and what they load) may run.

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.

A concrete policy for production and a separate one for development

Django 5.2 does not ship a Content Security Policy middleware, so most projects use django-csp. With its 4.x configuration format, a policy that fits a Vite build served from your own origin is short:

# settings.py
from csp.constants import NONCE, NONE, SELF

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "csp.middleware.CSPMiddleware",
    # ...
]

CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": [SELF],
        "script-src": [SELF, NONCE],
        "style-src": [SELF],
        "img-src": [SELF],
        "font-src": [SELF],
        "connect-src": [SELF],
        "object-src": [NONE],
        "base-uri": [SELF],
        "form-action": [SELF],
        "frame-ancestors": [NONE],
    },
}

Every line in that policy is possible only because of the habits above. style-src 'self' works because Vite extracts CSS into files in production builds. img-src 'self' without data: works because assetsInlineLimit: 0 stops Vite from turning small icons into data URIs. script-src needs the nonce only for the genuinely inline pieces — the import map, if you use one.

Development is a different story, and pretending otherwise is how 'unsafe-inline' ends up in production. The Vite dev server injects CSS by creating <style> elements from JavaScript, loads modules from another origin, and keeps a WebSocket open for hot reload. Rather than weakening the production policy, extend it only when the dev server is active:

from csp.constants import UNSAFE_INLINE

if DEBUG and VITE_DEV_SERVER:
    _d = CONTENT_SECURITY_POLICY["DIRECTIVES"]
    _d["script-src"] = [*_d["script-src"], "http://localhost:5173"]
    _d["style-src"] = [*_d["style-src"], UNSAFE_INLINE]        # dev-only: HMR style injection
    _d["connect-src"] = [*_d["connect-src"], "ws://localhost:5173", "http://localhost:5173"]
    _d["font-src"] = [*_d["font-src"], "http://localhost:5173"]
    _d["img-src"] = [*_d["img-src"], "http://localhost:5173"]

Because this block is guarded by the same flag that switches the template tag into dev mode, it is impossible to ship the relaxed policy without also shipping a site that tries to load its JavaScript from localhost — a failure nobody misses in staging.

Serving the bundle from a CDN

A CDN for static files changes the origin of every asset. Set Vite's base to the full CDN URL (for example https://cdn.example.com/static/build/), add that host to script-src, style-src and font-src, and make sure the CDN sends Access-Control-Allow-Origin for your site. Module scripts are always fetched in CORS mode, so a CDN that works for classic scripts can still fail every type="module" request with an opaque network error. Dynamic imports need nothing extra: they are ordinary script fetches governed by the same script-src.

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.

Serving hashed files with the right cache headers

Content hashing only pays off if the server tells browsers and CDNs that hashed files never change. Files under /static/build/assets/ can be cached for a year and marked immutable, so browsers skip even the revalidation request on reload. Everything else under /static/ — favicons, robots.txt, anything without a hash in its name — needs a short lifetime. With nginx in front of the application:

location /static/build/assets/ {
    alias /srv/app/staticfiles/build/assets/;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

# Source maps are build metadata, not public assets.
location ~ ^/static/build/.*\.map$ {
    return 404;
}

location /static/ {
    alias /srv/app/staticfiles/;
    add_header Cache-Control "public, max-age=3600";
}

The middle block deserves attention. collectstatic copies almost everything in static_build/ into the public static root. The manifest is the exception: the default ignore patterns skip dot-prefixed names such as .vite/, which is one reason the template tag reads the manifest from static_build/ directly rather than from STATIC_ROOT. Source maps have no such protection, and they contain your original, commented source code. Upload them to your error tracker during the build, then either delete them before collectstatic or block them at the proxy as above. In nginx a matching regex location takes precedence over a plain prefix location, which is why the 404 rule wins even though /static/build/assets/ is a longer prefix.

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.

Deploys, stale chunks, and the missing-file window

Content hashing creates a problem that unhashed files never had: after a deploy, the old filenames stop existing. Consider a user who loaded a page five minutes before your release. Their HTML references main-C3vN8q1d.js, which is already cached, but the first time they open the revenue chart the entry tries to import() revenue-R0x5sLp2.js — a chunk that the new release replaced with revenue-Hq7t2Wn9.js. If the old file is gone, the dynamic import rejects and the chart never appears.

The same window opens during a rolling deploy behind a load balancer. A request for the HTML is answered by a new container, which references new chunk names; the follow-up request for the chunk lands on an old container, which has never heard of it. Both scenarios have the same root cause — each container only carries the assets of its own release — and the same fixes.

Keep old assets around

The robust fix is to serve static files from somewhere that accumulates releases instead of replacing them: an object-storage bucket behind a CDN, or a shared directory such as /srv/app/static-releases/ that every release adds to. Because hashed filenames never collide, uploading a new build next to the old one is safe, and nothing that any live page can reference ever disappears. Prune files older than a few releases on a schedule, not at deploy time.

Recover gracefully in the browser

Even with old assets retained, a user may keep a tab open across several releases. Vite dispatches a vite:preloadError event on window when a dynamic import fails to load its dependencies. A single reload fetches fresh HTML with current chunk names; guard it so a genuinely broken build cannot trap the page in a reload loop:

// frontend/src/main.js
window.addEventListener("vite:preloadError", () => {
  const key = "reloaded-after-deploy";
  if (sessionStorage.getItem(key)) return;   // already tried once; let the error surface
  sessionStorage.setItem(key, "1");
  window.location.reload();
});

window.addEventListener("load", () => {
  // A successful full load means the page is consistent again.
  sessionStorage.removeItem("reloaded-after-deploy");
});

Troubleshooting reference

Most integration failures present as one of a handful of symptoms. This table maps each to its usual cause and the quickest check:

SymptomUsual causeHow to confirm and fix
Page renders unstyled; no JavaScript runs; 404s for /static/build/...base, the STATICFILES_DIRS prefix, and STATIC_URL disagreePick one hashed filename from the manifest and run python manage.py findstatic build/assets/<that file>. If it finds nothing, the prefix or directory is wrong.
ImproperlyConfigured: Vite manifest not foundBuild did not run, or the path predates Vite 5's .vite/ directoryList static_build/.vite/ after the build step; make sure the Docker stage copies the whole directory.
CSS missing on first paint, appears after JavaScript loadsTag only emits the entry's own css, not CSS from imported chunksCheck the manifest for css on shared chunks; use the import-graph walk shown above.
Dev server: page loads but fonts, images, or workers failserver.origin not set, so asset URLs are relative to Django's originSet server.origin to the dev server URL and extend the dev CSP for its origin.
Console: "Refused to load the image 'data:...'"Vite inlined a small asset as a data URISet build.assetsInlineLimit: 0 and rebuild.
Module scripts fail only when served from the CDNCDN does not send CORS headers; module scripts require themInspect the response for Access-Control-Allow-Origin; configure it on the CDN.
"Failed to fetch dynamically imported module" after a deployOld chunk deleted while pages referencing it are still openRetain previous releases' assets; add the vite:preloadError handler.

When none of these match, compare three things side by side: the URL the browser requested (network panel), the URL the manifest says the file has (static_build/.vite/manifest.json), and the path where the file actually lives after collectstatic (STATIC_ROOT). The mismatch is almost always visible within a minute.

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.