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 |
|
|
URLs in templates |
|
|
Development |
|
|
Production |
|
collectstatic: from source tree to STATIC_ROOT, Serving the collected files in production |
Cache busting |
|
|
Fast and simple hosting |
WhiteNoise middleware directly after |
|
CDN |
Put the CDN in front of the origin, or set |
|
Widget and form scripts |
An inner |
|
Bundlers |
Build with Vite into a folder that |
|
React, Vue or Angular |
Choose between a Django-served shell, a separate origin with CORS, or no SPA at all. |
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.
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"
Source: Django: Configuring static files and STATIC_ROOT
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
Source: Django: findstatic
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>
Source: Django: static template tag
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)
Source: Django: Finders module
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
Source: Django: runserver (staticfiles)
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
Source: Django: Static file development view and Serving files uploaded by a user during development
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())
Source: Django: StaticLiveServerTestCase and Testing static files
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 |
|---|---|
|
Never prompt; required in CI and Docker builds. |
|
Delete existing files in |
|
Do everything except modify the file system. |
|
Skip files matching a glob; repeatable. The defaults ignore |
|
Symlink instead of copy (a development convenience). |
|
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.
Source: Django: collectstatic
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",
},
}
Source: Django: ManifestStaticFilesStorage and STORAGES
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 |
Fastest and simplest to reason about; the build must run |
Dedicated static 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 |
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
Source: Django: STATIC_URL
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",
),
]
}
Source: Django: Paths as objects
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"]}
Source: Django: Media on forms
{# 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 %}
Source: Django: Media on forms
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">
Source: Django: Media objects
The last line is the second widget’s own media. Rules worth knowing:
-
Relative paths get
STATIC_URLprepended (through thestaticfilesstorage, so hashed names apply); paths starting with/,http://orhttps://are left alone. IfSTATIC_URLisNone,MEDIA_URLis used. -
Mediais inherited from the parent widget; addextend = Falseto the inner class to stop that, or define amediaproperty returningforms.Media(css=…, js=…)for full control. -
Media objects add together (
w1.media + w2.media), keeping relative order and removing duplicates; a contradictory order raisesMediaOrderConflictWarning. Indexing (w.media["css"]) returns only that kind. -
{{ form.media }}belongs in the<head>(or before the closing</body>for scripts, withdefer), once per page. Admin pages already do this for their widgets. -
Under a Content-Security-Policy with nonces, 6.1 adds the
csp_nonce_attrtemplate tag, which can render aMediaobject’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.
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
},
});
Source: Vite: Backend integration
$ 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"]
}
}
Source: Vite: Building for production
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", "")
Source: Django: STATICFILES_DIRS
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)
Source: Vite: Backend integration and Django: simple tags
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>
Source: Vite: Backend integration
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)
Source: Vite: Backend integration
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 |
Content sites, internal tools, forms-heavy back-office pages. |
SPA served by Django |
One origin. |
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/
]
Source: Django: Using regular expressions and manifest_hash
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
Source: Django: Overriding settings
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 |
|
|
|
|
A template references a path that no finder finds (typo, missing app namespace). |
Old CSS after a deployment |
Unhashed names behind a long cache. Use a manifest storage; WhiteNoise’s short |
|
|
The admin looks unstyled behind a proxy |
|
The wrong file wins |
Two files with the same relative path. Namespace app files and check the order with |
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 %}. -
CachedStaticFilesStoragewas removed in 3.1;ManifestStaticFilesStoragereplaces it. -
The
STATICFILES_STORAGEandDEFAULT_FILE_STORAGEsettings were deprecated in 4.2 and removed in 5.1; theSTORAGESdict with astaticfilesalias replaces the first. -
The project template’s
STATIC_URLlost its leading slash in 4.0 ('static/'), and a relative value is prefixed withSCRIPT_NAMEsince 3.1. -
Form
Mediastopped printingtype="text/javascript"(3.1) andtype="text/css"(4.1), and now acceptsScript(5.2) andStylesheet(6.1) objects. -
The
allargument offinders.find()becamefind_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 |
4.0 |
|
4.1 |
CSS source-map references are rewritten; |
4.2 |
|
5.1 |
|
5.2 |
|
6.0 |
Manifest files list paths in a stable order (smaller, more reproducible diffs); |
6.1 |
|