Static Files & Front-End Assets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, with WhiteNoise 6.12.0 (2026-02-27) and Vite 8.3 (8.3.2 was installed), all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a copy of the scratch project on Python 3.13 and Django 6.1.1 with SQLite: manage.py check, 16 tests (manage.py test), a collectstatic run, a real vite build and curl sessions against runserver, Uvicorn and Gunicorn passed, and the output shown below is real output (absolute paths shortened). Nothing here needs PostgreSQL or Redis. No CDN, S3 bucket or browser was used: where an example depends on one, the page says what was and was not exercised. In the Bookshelf application that grows across this section, static files are the site stylesheet, the logo, a date-picker widget and, at the end, a small single-page application built with Vite. Static files are the part of a Django deployment that works by accident in development and fails in production, so this page follows the files from the source tree to the browser in both situations.

In a hurry: what goes where

Concern Rule of thumb Section

Where files live

static/<app>/…​ inside each app (namespaced by the app name) plus project-wide folders in STATICFILES_DIRS.

Configuring staticfiles

URLs in templates

{% load static %} and {% static 'app/file.css' %}, never a hand-written /static/…​ path.

The static tag

Development

runserver serves files through finders when DEBUG = True; other servers need staticfiles_urlpatterns().

Serving files during development

Production

collectstatic copies everything to STATIC_ROOT; something other than Django (web server, CDN, WhiteNoise) serves it.

collectstatic: from source tree to STATIC_ROOT, Serving the collected files in production

Cache busting

ManifestStaticFilesStorage (or its WhiteNoise subclass) puts a content hash in every file name; far-future caching becomes safe.

Cache busting with ManifestStaticFilesStorage

Fast and simple hosting

WhiteNoise middleware directly after SecurityMiddleware plus CompressedManifestStaticFilesStorage.

WhiteNoise

CDN

Put the CDN in front of the origin, or set STATIC_URL to the CDN URL and push files with a custom storage.

Putting a CDN in front

Widget and form scripts

An inner Media class, rendered with {{ form.media }}; Script and Stylesheet objects add attributes.

Form assets: the Media class

Bundlers

Build with Vite into a folder that STATICFILES_DIRS exposes, read Vite’s manifest.json from a template tag.

Integrating a bundler: Vite

React, Vue or Angular

Choose between a Django-served shell, a separate origin with CORS, or no SPA at all.

Pairing Django with React, Vue or Angular

The two lives of a static file

A static file is any file that is the same for every user and does not need Python to produce it: stylesheets, scripts, images, fonts, a bundler’s output. Django separates three ideas that are easy to blur: where the files are found (finders, in the source tree), where they are collected and hashed (STATIC_ROOT, by collectstatic), and who serves them to a browser (the development server, a web server, WhiteNoise or a CDN). The django.contrib.staticfiles app owns the first two, and the staticfiles alias of the STORAGES setting (4.2) decides how URLs are built and how files are post-processed.

flowchart LR subgraph build["Build time"] src["app/static and STATICFILES_DIRS"] --> finders["Finders"] vitebuild["vite build -> dist"] --> finders finders --> collect["collectstatic"] collect --> post["post_process: hash names,
rewrite url() and source maps"] post --> root["STATIC_ROOT
+ staticfiles.json"] end subgraph run["Run time"] tpl["Template: static tag"] --> storage["staticfiles storage .url()"] storage -->|"hashed URL"| browser["Browser"] browser --> server["Web server, WhiteNoise or CDN
serves STATIC_ROOT"] end root --> server

In development the build box disappears: with DEBUG = True, runserver asks the finders for each file on every request, so edits show up immediately and nothing is hashed. In production the right-hand side must never reach Python for a static request. Static requests also do not go through your normal view code, and in development they bypass MIDDLEWARE altogether; see The Request/Response Cycle and Middleware for the cycle that normal views do follow.

The Bookshelf scratch project

The listings belong to the catalog app of the running Bookshelf scenario. The pieces are: catalog/static/catalog/ (css/site.css, js/app.js, img/logo.svg, plus the date-picker files), a project-level static/site-wide.css, templates catalog/base.html, book_form.html and spa.html, widgets.py, forms.py, storage.py, a vite template-tag library, a frontend/ folder holding a Vite project, and catalog/tests.py. The general layout of a project is on Project Structure & Apps and the full settings story on Settings & Configuration; this page shows only the static-related settings.

Configuring staticfiles

Three settings are enough to get started, and the app must be listed in INSTALLED_APPS (it is in the project template).

# config/settings.py (excerpt)
import os
from pathlib import Path

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

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]        # never committed; see Settings & Configuration
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "catalog",
]

STATIC_URL = "static/"                   # URL prefix; relative values follow SCRIPT_NAME (3.1)
STATIC_ROOT = BASE_DIR / "staticfiles"   # where collectstatic writes; never a source directory
STATICFILES_DIRS = [BASE_DIR / "static"] # extra source directories, searched before the apps

MEDIA_URL = "media/"                     # user uploads: a different prefix and a different root
MEDIA_ROOT = BASE_DIR / "media"

STATIC_ROOT and MEDIA_ROOT must differ, and so must STATIC_URL and MEDIA_URL; a system check enforces both. Uploaded files are covered on File Uploads & Storage.

Where files live and how they are found

A finder maps a relative path such as catalog/css/site.css to a file on disk. The two default finders are FileSystemFinder (the directories in STATICFILES_DIRS) and AppDirectoriesFinder (the static/ folder of every installed app, in INSTALLED_APPS order). STATICFILES_FINDERS can add the others or reorder them. The first match wins, which is why app files go in a folder named after the app: catalog/static/catalog/css/site.css is addressed as catalog/css/site.css, and a second app can ship its own css/site.css without a collision. A STATICFILES_DIRS entry may also be a (prefix, path) pair, which exposes a folder under a URL sub-path; the Vite section uses that.

$ python manage.py findstatic catalog/css/site.css site-wide.css
Found 'catalog/css/site.css' here:
  .../catalog/static/catalog/css/site.css
Found 'site-wide.css' here:
  .../static/site-wide.css

$ python manage.py findstatic catalog/css/site.css --verbosity 2
Found 'catalog/css/site.css' here:
  .../catalog/static/catalog/css/site.css
Looking in the following locations:
  .../static
  .../django/contrib/admin/static
  .../catalog/static

findstatic is the debugging aid: it shows which file a path resolves to and the exact search order, which is the first thing to check when the wrong version of a file is served. From code, finders.find() returns the same answer and finders.searched_locations lists where it looked. Since 6.1 the find_all argument of find() replaced all (deprecated in 5.2, removed in 6.1), so old code that passes all=True fails on 6.1.

The static tag

Templates must never contain a literal /static/…​ URL. The static tag asks the staticfiles storage for the URL, so changing STATIC_URL, moving to a CDN or turning on hashed names needs no template edits.

{# catalog/templates/catalog/base.html #}
{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{% block title %}Bookshelf{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'catalog/css/site.css' %}">
  {% block extra_head %}{% endblock %}
</head>
<body>
  <h1 class="logo">Bookshelf</h1>
  {% block content %}{% endblock %}
  <script src="{% static 'catalog/js/app.js' %}" defer></script>
</body>
</html>

Three details matter. The tag lives in the static library, loaded in every template that uses it (the old staticfiles and admin_static libraries were removed in 3.0). It works with any storage, because it simply calls .url() on the staticfiles storage. And {% static 'x' as url %} stores the URL in a variable, handy for inline CSS or JSON. get_static_prefix and get_media_prefix return only the prefix, and the static() context processor adds STATIC_URL to every RequestContext. The template language itself is on Templates.

The tests that pin these behaviours are short.

# catalog/tests.py (excerpt)
from django.contrib.staticfiles import finders
from django.template import Context, Template
from django.test import TestCase


class StaticTagTests(TestCase):
    def test_static_tag_uses_static_url(self):
        html = Template("{% load static %}{% static 'catalog/css/site.css' %}").render(Context())
        self.assertEqual(html, "/static/catalog/css/site.css")

    def test_finders_locate_app_and_project_files(self):
        self.assertTrue(finders.find("catalog/css/site.css"))
        self.assertTrue(finders.find("site-wide.css"))
        self.assertIsNone(finders.find("catalog/css/missing.css"))

    def test_page_links_assets(self):
        body = self.client.get("/").content.decode()
        self.assertIn('href="/static/catalog/css/site.css"', body)

Serving files during development

With django.contrib.staticfiles installed and DEBUG = True, runserver serves static files itself, straight from the finders and without collectstatic. The files are not served through MIDDLEWARE. Two options change that: --nostatic turns the behaviour off (for example when a front-end dev server or a proxy serves the files), and --insecure forces it with DEBUG = False (it cannot work with ManifestStaticFilesStorage and must never be used in production).

$ python manage.py runserver 127.0.0.1:8132
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:8132/static/catalog/css/site.css
200 text/css
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8132/static/admin/css/base.css
200

runserver is special. An ASGI or WSGI server started another way (Uvicorn or Gunicorn, for example) does not serve static files unless the URLconf does it, with the development view that the staticfiles app provides:

# config/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("catalog.urls")),
]

if settings.DEBUG:
    # Development only: both helpers refuse to work with DEBUG = False.
    urlpatterns += staticfiles_urlpatterns()                                   # app + STATICFILES_DIRS files
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)  # uploads

Started with DJANGO_DEBUG=1 uvicorn config.asgi:application --port 8132, the same curl as above answers 200. The helpers need a local prefix (static/), not a full URL, and they only work with DEBUG = True. The Channels setup on Channels & WebSockets puts "daphne" before django.contrib.staticfiles in INSTALLED_APPS, because Daphne overrides runserver and the system check daphne.E001 fails the other way round.

Testing with real HTTP

The test client does not serve static files. A browser-driven test (Selenium or Playwright, see pytest, Factories & Browser Tests and End-to-End Testing with Real Browsers) does, and LiveServerTestCase only knows files already collected under STATIC_ROOT. StaticLiveServerTestCase serves through the finders instead, as runserver does.

import urllib.request

from django.contrib.staticfiles.testing import StaticLiveServerTestCase


class LiveStaticTests(StaticLiveServerTestCase):
    def test_live_server_serves_finder_files_without_collectstatic(self):
        url = self.live_server_url + "/static/catalog/css/site.css"
        with urllib.request.urlopen(url) as resp:
            self.assertEqual(resp.status, 200)
            self.assertIn(b".logo", resp.read())

collectstatic: from source tree to STATIC_ROOT

collectstatic walks every finder and copies each file into STATIC_ROOT, then calls the post_process() method of the staticfiles storage if it has one. Duplicate names are resolved like template names: the first location wins (findstatic shows which). On later runs a file is copied only if its timestamp is newer than the one in STATIC_ROOT, so after removing an app from INSTALLED_APPS use --clear to drop stale files. The options used most:

Option Effect

--noinput

Never prompt; required in CI and Docker builds.

--clear (-c)

Delete existing files in STATIC_ROOT first.

--dry-run (-n)

Do everything except modify the file system.

--ignore PATTERN (-i)

Skip files matching a glob; repeatable. The defaults ignore CVS, .* and *~ (switch off with --no-default-ignore).

--link (-l)

Symlink instead of copy (a development convenience).

--no-post-process

Skip hashing and rewriting (fast, but then names are not versioned).

$ python manage.py collectstatic --noinput
140 static files copied to '.../staticfiles', 402 post-processed.

$ python manage.py collectstatic --noinput --dry-run -v 1
0 static files copied to '.../staticfiles', 140 unmodified.

The run above used the production settings of the next section, hence "post-processed". In a deployment pipeline collectstatic runs at build time (a Docker image layer, a CI job) and its output directory is what ships; the pipeline side is on Deployment and Docker & Kubernetes. The admin and any third-party app contribute their files too, which is why the count includes 100+ admin files. Since 6.0 the command prints only a summary for skipped (and, with --clear, deleted) files at verbosity 1; use --verbosity 2 for per-file lines.

Cache busting with ManifestStaticFilesStorage

Browsers and CDNs cache static files aggressively, which is what you want until a file changes. The robust answer is to put a fingerprint of the content in the file name: a changed file has a new URL, an unchanged one keeps its URL and its cache entry. ManifestStaticFilesStorage does this at collectstatic time: css/site.css is also saved as css/site.<hash>.css, where the hash is the first 12 hex characters of the MD5 of the content, and the mapping is written to staticfiles.json. At run time static reads the manifest, so no hashing happens per request. Select it through STORAGES; defining STORAGES replaces the defaults, so default must be restated.

# config/settings_prod.py (excerpt)
from .settings import *  # noqa: F403

DEBUG = False

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

The storage works only when STORAGES["staticfiles"] points at it, DEBUG is False and collectstatic has run. The post-processing also rewrites references between files: url() and @import in CSS, and source-map comments in CSS (4.1) and JavaScript (4.0). The logo reference in site.css shows it:

/* staticfiles/catalog/css/site.9b684a12deb2.css (generated) */
.logo { background: url("../img/logo.16f49302f5a1.svg") no-repeat; padding-left: 40px; }

Both the original and the hashed copy stay in STATIC_ROOT. That is deliberate: pages cached somewhere that still reference the old file keep working while the new version rolls out. A template rendered under these settings produces the hashed URL:

<link rel="stylesheet" href="/static/catalog/css/site.9b684a12deb2.css">
...
<script src="/static/catalog/js/app.34e006b0cbad.js" defer></script>

Testing against the manifest

The storage needs a manifest, and the test runner does not run collectstatic, so the official advice is to keep the default StaticFilesStorage in tests. A test that wants to prove the hashing builds its own output directory:

import json
import tempfile
from pathlib import Path

from django.core.management import call_command
from django.template import Context, Template
from django.test import SimpleTestCase, override_settings

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


class ManifestStorageTests(SimpleTestCase):
    def test_collectstatic_hashes_names_and_rewrites_css(self):
        with tempfile.TemporaryDirectory() as root:
            with override_settings(STATIC_ROOT=root, STORAGES=MANIFEST_STORAGES, DEBUG=False):
                call_command("collectstatic", interactive=False, verbosity=0)
                manifest = json.loads((Path(root) / "staticfiles.json").read_text())
                hashed = manifest["paths"]["catalog/css/site.css"]
                self.assertRegex(hashed, r"^catalog/css/site\.[0-9a-f]{12}\.css$")
                html = Template("{% load static %}{% static 'catalog/css/site.css' %}").render(Context())
                self.assertEqual(html, "/static/" + hashed)
                with self.assertRaises(ValueError):   # not in the manifest
                    Template("{% load static %}{% static 'catalog/css/nope.css' %}").render(Context())

The ValueError is the failure you will meet in production: a template references a file that is missing from the manifest, usually because collectstatic was not run after adding it. That makes it a feature, not a bug: the error shows up on the first request instead of as a silent 404 in the browser. manifest_strict = False in a subclass relaxes it, but not as far as the documentation’s wording suggests: in the scratch run a file that exists in storage but is missing from the manifest got its hashed name computed on the fly (a hash read per request), and a file that does not exist at all still raised ValueError.

from django.contrib.staticfiles.storage import ManifestStaticFilesStorage


class Lenient(ManifestStaticFilesStorage):
    manifest_strict = False

# storage.url("late.css") -> "/static/late.<hash>.css" when late.css exists but is not in the manifest

Tuning the storage

The class exposes a few hooks, all by subclassing. manifest_storage (4.0) moves staticfiles.json somewhere other than STATIC_ROOT; max_post_process_passes (default 5) bounds the number of rewriting passes for files that reference each other; support_js_module_import_aggregation (experimental, 4.2) also rewrites JavaScript import and export …​ from paths; file_hash() replaces the hash function; file_permissions_mode and directory_permissions_mode change the permissions of collected files (they default to FILE_UPLOAD_PERMISSIONS and FILE_UPLOAD_DIRECTORY_PERMISSIONS).

# catalog/storage.py
from django.conf import settings
from django.contrib.staticfiles.storage import (
    ManifestStaticFilesStorage,
    StaticFilesStorage,
)


class SiteManifestStorage(ManifestStaticFilesStorage):
    """Keep staticfiles.json next to manage.py and follow JS module imports."""

    support_js_module_import_aggregation = True
    max_post_process_passes = 8

    def __init__(self, *args, **kwargs):
        manifest_storage = StaticFilesStorage(location=settings.BASE_DIR)
        super().__init__(*args, manifest_storage=manifest_storage, **kwargs)

A test with STORAGES["staticfiles"]["BACKEND"] = "catalog.storage.SiteManifestStorage" confirmed that collectstatic wrote staticfiles.json to BASE_DIR and nothing to STATIC_ROOT. Two cautions from the reference: a file’s integrity attribute (Subresource Integrity) must be computed on the file as served, and Django has no out-of-the-box tool for that; and manifest_hash, a single hash that changes whenever any file in the manifest changes (4.2), is meant for single-page applications that want to detect a new deployment, as the Pairing Django with React, Vue or Angular section shows.

Serving the collected files in production

After collectstatic the work moves outside Django. The official guide describes four shapes; the right one depends on traffic and on who runs the infrastructure.

Shape How Trade-off

Same web server as the site

Nginx or Apache serves STATIC_ROOT at STATIC_URL; the app server handles the rest.

Fastest and simplest to reason about; the build must run collectstatic on that host.

Dedicated static server

collectstatic runs at build time and the folder is pushed (for example with rsync) to a separate server.

Scales independently; one more thing to deploy and to keep in sync with the code.

The app serves its own files

A community package does it inside the WSGI/ASGI process; WhiteNoise is the usual choice.

No extra infrastructure; the Python process answers static requests, so put a CDN in front for real traffic.

Cloud storage or CDN

A custom staticfiles storage backend pushes files to a bucket; STATIC_URL points at the CDN.

Offloads traffic completely; credentials and cache invalidation become yours to manage.

WhiteNoise

WhiteNoise is a community package (not part of Django) that serves the collected files from inside the application, with correct Cache-Control headers, gzip and optional Brotli versions, and ETag support. It is a good fit for containers and platform-as-a-service hosting, and it is also what most small deployments need. Installation is a package (pip install whitenoise; add the brotli extra for Brotli) and two settings: the middleware, directly after SecurityMiddleware and before everything else, and its storage class.

# config/settings_prod.py (excerpt)
from .settings import *  # noqa: F403

DEBUG = False

MIDDLEWARE.insert(
    MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1,
    "whitenoise.middleware.WhiteNoiseMiddleware",
)

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

CompressedManifestStaticFilesStorage combines Django’s manifest hashing with compression (gzip files, and Brotli when the package is installed, are written next to the originals only when they are smaller). The alternative CompressedStaticFilesStorage compresses without hashing. After collectstatic, Gunicorn served the site and curl showed what the browser would see:

$ DJANGO_SETTINGS_MODULE=config.settings_prod DJANGO_DEBUG=0 \
    gunicorn config.wsgi -b 127.0.0.1:8132 --log-level warning &
$ curl -sI http://127.0.0.1:8132/static/catalog/css/site.9b684a12deb2.css
HTTP/1.1 200 OK
Content-Type: text/css; charset="utf-8"
Cache-Control: max-age=315360000, public, immutable
Access-Control-Allow-Origin: *
...
$ curl -sI -H 'Accept-Encoding: gzip' http://127.0.0.1:8132/static/admin/css/base.css
HTTP/1.1 200 OK
Cache-Control: max-age=60, public
Vary: Accept-Encoding
Content-Encoding: gzip
...

The two Cache-Control values show the design. WhiteNoise recognises hashed names and marks them cacheable forever and immutable; unhashed files get a short max-age (WHITENOISE_MAX_AGE, 60 seconds by default). Access-Control-Allow-Origin: * comes from WHITENOISE_ALLOW_ALL_ORIGINS (default True) so that fonts and canvas images keep working when a CDN serves the files from another domain. The other settings you will meet are WHITENOISE_ROOT (serve a folder such as one with robots.txt at the site root), WHITENOISE_USE_FINDERS and WHITENOISE_AUTOREFRESH (both default to DEBUG; development conveniences), WHITENOISE_INDEX_FILE, WHITENOISE_MIMETYPES and WHITENOISE_ADD_HEADERS_FUNCTION. WhiteNoise 6.12.0 lists Django up to 6.0 in its package classifiers; the scratch project ran it on Django 6.1.1 without problems, but treat that as "works", not as a stated guarantee. WhiteNoise serves static files only. Uploaded media do not belong to it (see File Uploads & Storage).

Putting a CDN in front

A content delivery network caches files at the edge. There are two ways to use one, and they combine well with hashed names.

The first is the pull CDN: the CDN’s origin is your site (the WhiteNoise or Nginx host) and it fetches and caches files on demand. Nothing changes in Django except STATIC_URL, which becomes the CDN URL. The static tag, the manifest and form Media then all produce CDN URLs (the official Media examples show the same effect).

# settings_prod.py
STATIC_URL = "https://cdn.example.com/static/"   # absolute: the static()/get_static_prefix helpers are not used
from django.template import Context, Template
from django.test import SimpleTestCase, override_settings


class CdnTests(SimpleTestCase):
    @override_settings(STATIC_URL="https://cdn.example.com/static/")
    def test_absolute_static_url(self):
        html = Template("{% load static %}{% static 'catalog/js/app.js' %}").render(Context())
        self.assertEqual(html, "https://cdn.example.com/static/catalog/js/app.js")

The second is the push model: collectstatic uploads straight to object storage. The how-to shows the shape, a custom backend named in STORAGES["staticfiles"]:

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "myproject.storage.S3Storage"},   # your own or a django-storages subclass
}

The class is a placeholder, as in the how-to. The django-storages S3 backend that File Uploads & Storage runs against a local S3 look-alike is the real starting point; a subclass that mixes in ManifestFilesMixin gives hashed names on a bucket. No real bucket or CDN was used for this page, so bucket policies, CORS rules for fonts and cache invalidation are exactly the parts it cannot vouch for. Two practical rules apply to any CDN: keep hashed names (so no invalidation is ever needed for static files), and never put user-specific or private responses behind the static prefix.

Form assets: the Media class

Some widgets need a stylesheet or a script to work: a date picker, a rich-text editor, an autocomplete. Django lets the widget (or the form) declare them, and merges and de-duplicates them for the page. The declaration is an inner Media class with css (a dict from output medium to a list of files) and js (a list). Entries can be strings or, since 5.2 for scripts and 6.1 for stylesheets, objects that carry HTML attributes.

# catalog/widgets.py
from django import forms
from django.forms import Script, Stylesheet


class DatePickerWidget(forms.DateInput):
    input_type = "text"

    class Media:
        css = {"all": ["catalog/css/datepicker.css"]}
        js = [
            "catalog/js/datepicker.js",
            Script("catalog/js/datepicker-init.js", defer=True),   # Script: 5.2
        ]


class PrintableMedia(forms.TextInput):
    class Media:
        css = {
            "all": [
                Stylesheet(                                          # Stylesheet: 6.1
                    "https://cdn.example.com/print.css",
                    crossorigin="anonymous",
                    media="print",
                ),
            ]
        }

A form adds its own assets with a Media class and inherits those of its widgets:

# catalog/forms.py
from django import forms

from .widgets import DatePickerWidget


class PublicationForm(forms.Form):
    title = forms.CharField(max_length=200)
    published = forms.DateField(widget=DatePickerWidget)

    class Media:
        css = {"all": ["catalog/css/form-layout.css"]}
{# catalog/templates/catalog/book_form.html #}
{% extends "catalog/base.html" %}
{% block extra_head %}{{ form.media }}{% endblock %}
{% block content %}
<form method="post">
  {% csrf_token %}
  {{ form }}
  <button type="submit">Save</button>
</form>
{% endblock %}

Printing the media shows what ends up in the page head:

<link href="/static/catalog/css/datepicker.css" media="all" rel="stylesheet">
<link href="/static/catalog/css/form-layout.css" media="all" rel="stylesheet">
<script src="/static/catalog/js/datepicker.js"></script>
<script src="/static/catalog/js/datepicker-init.js" defer></script>
<link href="https://cdn.example.com/print.css" crossorigin="anonymous" media="print" rel="stylesheet">

The last line is the second widget’s own media. Rules worth knowing:

  • Relative paths get STATIC_URL prepended (through the staticfiles storage, so hashed names apply); paths starting with /, http:// or https:// are left alone. If STATIC_URL is None, MEDIA_URL is used.

  • Media is inherited from the parent widget; add extend = False to the inner class to stop that, or define a media property returning forms.Media(css=…​, js=…​) for full control.

  • Media objects add together (w1.media + w2.media), keeping relative order and removing duplicates; a contradictory order raises MediaOrderConflictWarning. Indexing (w.media["css"]) returns only that kind.

  • {{ form.media }} belongs in the <head> (or before the closing </body> for scripts, with defer), once per page. Admin pages already do this for their widgets.

  • Under a Content-Security-Policy with nonces, 6.1 adds the csp_nonce_attr template tag, which can render a Media object’s assets with the nonce applied. That tag was not exercised here; see Content Security Policy for the setup.

The tests for this section render the media and assert the exact tags:

from django.test import SimpleTestCase

from .forms import PublicationForm
from .widgets import DatePickerWidget, PrintableMedia


class MediaTests(SimpleTestCase):
    def test_widget_media_renders_script_objects(self):
        html = str(DatePickerWidget().media)
        self.assertIn('<script src="/static/catalog/js/datepicker-init.js" defer></script>', html)

    def test_stylesheet_object_attributes(self):
        html = str(PrintableMedia().media)
        self.assertIn('crossorigin="anonymous"', html)
        self.assertIn('media="print"', html)

    def test_form_media_merges_widgets_and_form(self):
        html = str(PublicationForm().media)
        self.assertIn("form-layout.css", html)
        self.assertIn("datepicker.js", html)

The widgets and rendering side of forms is on Form Rendering & Widgets. Media is best for assets that belong to a widget. For site-wide CSS and JavaScript use the static tag in the base template, and for anything bigger than a few small scripts, use a bundler.

Integrating a bundler: Vite

Django has no opinion about JavaScript tooling. When the front end outgrows a few <script> tags (modules, TypeScript, npm packages, a framework) the standard move is a bundler that builds into a folder of hashed files, with Django serving that folder like any other static content. Vite is the common choice. Its official backend-integration guide describes the contract, and Django only has to fulfil two parts of it: serve the build output, and render the right tags.

flowchart TB subgraph dev["Development"] vdev["vite dev server
port 5173, HMR"] -->|"module scripts"| bd["Browser"] dj["runserver
HTML + API"] --> bd end subgraph prod["Production"] vb["vite build
dist/ + .vite/manifest.json"] --> cs["collectstatic
(hash again)"] cs --> wn["WhiteNoise / CDN"] tag["vite_entry tag
reads manifest.json"] --> html["HTML with hashed tags"] end

The Vite side

The configuration must agree with Django on one thing: the URL prefix. base is the URL under which the build will be served, so it is STATIC_URL plus the prefix that STATICFILES_DIRS assigns to Vite’s output folder. build.manifest makes Vite write .vite/manifest.json, which maps each entry to its hashed output files.

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

export default defineConfig({
  base: "/static/vite/",          // STATIC_URL + the "vite" prefix below
  build: {
    manifest: true,               // writes dist/.vite/manifest.json
    outDir: "dist",
    emptyOutDir: true,
    rolldownOptions: {
      input: { main: resolve(import.meta.dirname, "src/main.js") },
    },
  },
  server: {
    port: 5173,
    strictPort: true,
    cors: { origin: "http://localhost:8000" },   // the Django origin that loads the dev scripts
  },
});
$ cd frontend && npm install --save-dev vite && npx vite build
vite v8.3.2 building client environment for production...
✓ 3 modules transformed.
dist/.vite/manifest.json       0.18 kB
dist/assets/main-BaNwpQDn.css  0.01 kB
dist/assets/main-CBf4I62I.js   0.03 kB

$ cat dist/.vite/manifest.json
{
  "src/main.js": {
    "file": "assets/main-CBf4I62I.js",
    "name": "main",
    "src": "src/main.js",
    "isEntry": true,
    "css": ["assets/main-BaNwpQDn.css"]
  }
}

The Vite guide names the entry option build.rolldownOptions.input (the bundler underneath Vite 8 is Rolldown); older Vite releases and many tutorials call it build.rollupOptions.input, so copied configurations may need that rename. The main.js entry here imports a stylesheet, which is why the manifest lists a css file for it. A real manifest also has imports (shared chunks that should be preloaded) and dynamicImports.

The Django side

Expose the output folder under the prefix, tell Django where the manifest is, and add a switch for the dev server:

# config/settings.py (excerpt)
STATICFILES_DIRS = [
    BASE_DIR / "static",
    ("vite", BASE_DIR / "frontend" / "dist"),   # served at STATIC_URL + "vite/"
]
VITE_MANIFEST = BASE_DIR / "frontend" / "dist" / ".vite" / "manifest.json"
# empty in production; during development: http://localhost:5173/static/vite
VITE_DEV_SERVER = os.environ.get("VITE_DEV_SERVER", "")

Because the dev server runs with base set, its URLs include the prefix: http://localhost:5173/static/vite/@vite/client answered 200 (with Access-Control-Allow-Origin echoing the configured origin) while http://localhost:5173/@vite/client answered 404. The setting above therefore ends in /static/vite. The template tag renders the dev-server tags or the production tags:

# catalog/templatetags/vite.py
import json
from functools import cache

from django import template
from django.conf import settings
from django.templatetags.static import static
from django.utils.html import format_html, format_html_join

register = template.Library()


@cache
def _manifest():
    with open(settings.VITE_MANIFEST, encoding="utf-8") as fh:
        return json.load(fh)


def _imported_chunks(manifest, name, seen=None):
    seen = set() if seen is None else seen
    for key in manifest[name].get("imports", []):
        if key not in seen:
            seen.add(key)
            yield from _imported_chunks(manifest, key, seen)
            yield manifest[key]


@register.simple_tag
def vite_entry(name):
    """Render the tags for one Vite entry point."""
    if settings.VITE_DEV_SERVER:
        origin = settings.VITE_DEV_SERVER
        return format_html(
            '<script type="module" src="{0}/@vite/client"></script>\n'
            '<script type="module" src="{0}/{1}"></script>',
            origin,
            name,
        )
    manifest = _manifest()
    chunks = list(_imported_chunks(manifest, name))
    entry = manifest[name]
    css = [f for chunk in [*chunks, entry] for f in chunk.get("css", [])]
    links = format_html_join(
        "\n", '<link rel="stylesheet" href="{}">',
        ((static(f"vite/{f}"),) for f in dict.fromkeys(css)),
    )
    script = format_html(
        '<script type="module" src="{}"></script>', static(f"vite/{entry['file']}")
    )
    preloads = format_html_join(
        "\n", '<link rel="modulepreload" href="{}">',
        ((static(f"vite/{c['file']}"),) for c in chunks),
    )
    return format_html("{}\n{}\n{}", links, script, preloads)

The tag follows the order the Vite guide prescribes for a backend (stylesheets of imported chunks, then the entry’s stylesheets, the entry script, optional module preloads) and builds its HTML with format_html, so file names are escaped. Authoring tags is covered on Custom Template Tags & Filters. In a template:

{# catalog/templates/catalog/spa.html #}
{% load vite %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bookshelf app</title>
  {% vite_entry "src/main.js" %}
</head>
<body>
  <div id="app"></div>
</body>
</html>

With the production settings, collectstatic and the page rendered through the test client produced:

<link rel="stylesheet" href="/static/vite/assets/main-BaNwpQDn.d92724ad98ba.css">
<script type="module" src="/static/vite/assets/main-CBf4I62I.1f83120c2598.js"></script>

Notice the double hash: Vite already fingerprints its output, and Django adds its own on top. The result works (the original Vite-named files are still in STATIC_ROOT, so a modulepreload or a JavaScript import that uses Vite’s names finds them), but it is redundant. If a project prefers one hash, keep the Vite output out of collectstatic’s post-processing (a storage subclass that skips the `vite/ prefix) or serve dist/ directly from the web server. With VITE_DEV_SERVER set the same template produces the dev tags instead. The tests assert both:

@override_settings(VITE_DEV_SERVER="")
class ViteTagTests(SimpleTestCase):
    def test_entry_tags_from_manifest(self):
        body = self.client.get("/app/").content.decode()
        self.assertIn('<script type="module" src="/static/vite/assets/main-', body)
        self.assertIn('<link rel="stylesheet" href="/static/vite/assets/main-', body)

    @override_settings(VITE_DEV_SERVER="http://localhost:5173/static/vite")
    def test_dev_server_tags(self):
        body = self.client.get("/app/").content.decode()
        self.assertIn('src="http://localhost:5173/static/vite/@vite/client"', body)

The hand-written tag keeps the mechanism visible. Packages exist that package the same idea (django-vite is the best known; its PyPI classifiers, checked 2026-10-03 for version 3.2.0, list Django only up to 4.2, and it was not run for this page), so evaluate one against your Django version before adopting it. A project that uses React with Vite and wants fast refresh must also inject the React refresh preamble that the Vite guide shows; the tag above does not.

Build order

The order matters in a pipeline: install npm dependencies, vite build, then collectstatic, because collectstatic copies whatever is in frontend/dist at that moment. In Docker this is a multi-stage build (a Node stage produces dist, the Python stage copies it and collects). The general production concerns are on Deployment; the front-end build concerns are on HTML & CSS performance and build optimization.

Pairing Django with React, Vue or Angular

A single-page application is a JavaScript program that renders in the browser and talks to the server in JSON. Django can be the host, the API, or both, and the first decision is whether you need an SPA at all. A server-rendered Django site with htmx-style partial updates (see Template Partials & htmx) gives fast pages, working links, SEO and accessibility by default, one codebase and one deployment. An SPA earns its cost when the product is an application rather than a set of documents: dense client-side state, offline use, rich editing, or a team and a mobile client that already live in JavaScript.

Approach Shape Choose it when

No SPA

Django templates, a little JavaScript through static and Media, htmx for partial updates.

Content sites, internal tools, forms-heavy back-office pages.

SPA served by Django

One origin. vite build output is collected and served; one catch-all view returns the HTML shell for every client-side route; the API lives under /api/.

One team, one deployment, session cookies and CSRF protection work as they do for ordinary pages.

SPA on its own origin

The SPA is hosted on a static host or CDN; Django is a pure API on another origin and must enable CORS.

Separate teams and release cycles, several clients (web and mobile) sharing one API.

Islands

Server-rendered pages with a few mounted components (a React or Vue widget on a Django page) built by Vite, using the same tag as above.

Mostly server-rendered pages with one or two truly interactive screens.

A SPA served by Django

The catch-all view returns the shell for every URL the router owns on the client, so deep links and page reloads work; the build version endpoint lets a long-lived tab notice that a new deployment replaced its assets (this uses the manifest_hash of the manifest storage from Tuning the storage).

# catalog/views.py (excerpt)
from django.contrib.staticfiles.storage import staticfiles_storage
from django.http import JsonResponse
from django.shortcuts import render


def spa(request):
    return render(request, "catalog/spa.html")


def build_version(request):
    """Tell a long-lived SPA whether a new static build was deployed."""
    return JsonResponse({"assets": getattr(staticfiles_storage, "manifest_hash", "dev")})
# catalog/urls.py
from django.urls import path, re_path

from . import views

app_name = "catalog"
urlpatterns = [
    path("", views.book_list, name="book-list"),
    path("publish/", views.publication, name="publish"),
    path("build-version/", views.build_version, name="build-version"),
    re_path(r"^app/", views.spa, name="spa"),   # the SPA owns every URL below /app/
]
class SpaTests(SimpleTestCase):
    def test_every_url_under_app_returns_the_shell(self):
        for url in ("/app/", "/app/books/42/edit"):
            with self.subTest(url=url):
                self.assertContains(self.client.get(url), '<div id="app"></div>')

    def test_build_version_changes_with_manifest(self):
        with tempfile.TemporaryDirectory() as root:
            with override_settings(STATIC_ROOT=root, STORAGES=MANIFEST_STORAGES, DEBUG=False):
                call_command("collectstatic", interactive=False, verbosity=0)
                first = self.client.get("/build-version/").json()["assets"]
                self.assertEqual(len(first), 12)    # 12 hex characters

URL ordering matters in a real project: API, admin and static prefixes first, and the catch-all last, otherwise it hides them. Authentication in this shape is the plain session cookie plus the CSRF token of Security; the SPA reads the csrftoken cookie and sends it in the X-CSRFToken header. The API itself is on Django REST Framework and Django Ninja.

A SPA on its own origin

When the SPA lives elsewhere, the browser enforces the same-origin policy and Django must opt in to cross-origin requests; the mechanism, the preflight and the Origin checks are explained on What is CORS?. Session cookies across sites also bring SameSite and CSRF trusted-origin decisions, so many teams pick token authentication for this shape. The decision needs the details from the security page and the API pages, not from this one. What static files add: the SPA is built and deployed by its own pipeline, and Django serves none of it.

Which framework section to read

The front-end sections of this site cover the other half of the pairing: React starts at Getting Started with React and its build story is on Build and Deployment; Vue’s setup and Vite tooling are on Tooling and Project Setup with deployment on Performance and Deployment; Angular starts at Getting Started with Angular and ships through its own CLI build, covered on Production and Performance. The Angular CLI build writes its own index.html with the hashed file names already injected, so for Angular the Django-served option means copying the CLI build output into a STATICFILES_DIRS folder and serving that index.html from the catch-all view, rather than reading a Vite manifest (this combination was not run here). React and Vue projects created with Vite can reuse the tag shown above unchanged.

Pitfalls

Symptom Cause and fix

Static files work with runserver but return 404 in production

runserver served them through finders. Run collectstatic and configure a real server, WhiteNoise or a CDN.

ValueError: Missing staticfiles manifest entry

ManifestStaticFilesStorage with a file collectstatic has not seen. Re-run collectstatic in the build, and keep the default storage in tests.

ValueError: The file '…​' could not be found

A template references a path that no finder finds (typo, missing app namespace). findstatic shows what is found.

Old CSS after a deployment

Unhashed names behind a long cache. Use a manifest storage; WhiteNoise’s short max-age only softens the problem.

daphne.E001 after adding Channels

"daphne" must be listed before django.contrib.staticfiles in INSTALLED_APPS.

The admin looks unstyled behind a proxy

STATIC_URL or the server mapping disagrees with where the files are. Compare findstatic, STATIC_URL and the proxy’s path.

The wrong file wins

Two files with the same relative path. Namespace app files and check the order with findstatic --verbosity 2.

Hashed file points at a missing image

The reference was built by string concatenation in JavaScript, which post-processing cannot see. Reference assets from CSS or build them with the bundler.

Where the books are outdated

Material written for Django 1.11 and 2.x editions of the books consulted for this section no longer applies in several places.

  • {% load staticfiles %} and {% load admin_static %} were removed in 3.0. Use {% load static %}.

  • CachedStaticFilesStorage was removed in 3.1; ManifestStaticFilesStorage replaces it.

  • The STATICFILES_STORAGE and DEFAULT_FILE_STORAGE settings were deprecated in 4.2 and removed in 5.1; the STORAGES dict with a staticfiles alias replaces the first.

  • The project template’s STATIC_URL lost its leading slash in 4.0 ('static/'), and a relative value is prefixed with SCRIPT_NAME since 3.1.

  • Form Media stopped printing type="text/javascript" (3.1) and type="text/css" (4.1), and now accepts Script (5.2) and Stylesheet (6.1) objects.

  • The all argument of finders.find() became find_all (5.2 deprecation, removed 6.1).

  • Treating the front end as "jQuery from a CDN plus a few scripts" no longer matches how projects are built: Vite or another bundler writes hashed output and a manifest, and Django serves it. The same books predate WhiteNoise’s role as the default small-site answer for serving static files from the application process.

What changed recently

Version Change

3.1

Relative STATIC_URL and MEDIA_URL are prefixed with SCRIPT_NAME; CachedStaticFilesStorage removed; STATICFILES_DIRS accepts pathlib.Path.

4.0

ManifestStaticFilesStorage rewrites JavaScript source-map references and gains the manifest_storage argument; the project template’s STATIC_URL is 'static/'.

4.1

CSS source-map references are rewritten; Media js and css accept hashable objects that implement __html__().

4.2

STORAGES setting (with a staticfiles alias); experimental JavaScript module import rewriting; manifest_hash.

5.1

STATICFILES_STORAGE and DEFAULT_FILE_STORAGE removed.

5.2

Script object for form media; finders.find() all argument deprecated for find_all.

6.0

Manifest files list paths in a stable order (smaller, more reproducible diffs); collectstatic summarises skipped and deleted files at verbosity 1.

6.1

Stylesheet object for form media; the csp_nonce_attr tag can apply a nonce to Media; the find() all argument is gone.