I've been building web applications with Vue and Django for a long time. I don't remember my first one—certainly before Vite was available. As soon as I switched to using Vite, I ended up building a template tag to join the frontend and backend together rather than having separate projects. I've always found things simpler to have Django serve everything.
While preparing this post to share the latest version of what is essentially a small set of files we copy between projects, I started exploring the idea of open-sourcing the solution.
The goal was twofold
- To create a reusable package instead of relying on copy-and-paste code, and
- To contribute something back to the open-source community.
In the process, I stumbled upon an excellent existing project — django-vite.
So now I think we might give this a good look to switch to and add a Redis backend.
For now though, I think it's still worth sharing our simple solution in case it's a better fit for you (I haven't fully examined django-vite yet).
The Problem
The problem we are trying to solve is using Vite to bundle/build our Vue frontend and yet have Django be able to serve the bundle entry point JS and CSS entry points automatically. Running vite build will yield output like:
main-2uqS21f4.js
main-BCI6Z1XL.cssWithout any extra tooling, we'd have to commit build output, hard-code these cache-busting file names to the base template, every time we made a change that could affect the bundle.
This was completely unacceptable.
The Solution
Vite offers the ability to generate a manifest file that will map the cache-busting file name with their base name in a machine readable format. This will allow us to leverage builds happening on CI/CD as part of our Docker image build, and then read the manifest produced by Vite, to keep everything neat and simple.
Here is the setting in the vite.config.ts key to this:
{
// ...
build: {
manifest: true,
// ...
}
// ...
}This will produce a file in your output folder (under .vite/) called manifest.json.
Here is a snippet; note that you typically won’t need to inspect it manually:
"main.ts": {
"file": "assets/main-2uqS21f4.js",
"name": "main",
"src": "main.ts",
"isEntry": true,
"imports": [
"_runtime-D84vrshd.js",
"_forms-OJiVtksU.js",
"_analytics-CCPQRNnj.js",
"_forms-pro-qreHBaUb.js",
"_icons-3wXMhf1p.js",
"_pv-DzJUpav-.js",
"_vue-mapbox-BRpo1ix7.js",
"_mapbox--vATkUHK.js"
],
"dynamicImports": [
"views/HomeView.vue",
"views/dispatch/DispatchNewOrdersView.vue",
...This is the key to tying things together dynamically. We constructed a template tag so that we could dynamically add our entry point in our base template:
{% load vite %}
<html>
<head>
<!-- ... base head template stuff -->
{% vite_styles 'main.ts' %}
</head>
<body>
<!-- ... base template stuff -->
{% vite_scripts 'main.ts' %}
</body>
</html>The idea behind this type of solution is conceptually pretty simple. The template tag needs to read the manifest.json, find the referenced entry point main.ts, then return the staticfiles based path to what's in the file key (e.g. assets/main-2uqS21f4.js before rendering the template).
Given this, we need to optimize by reducing file I/O hits on every request, and since we’ll use caching we must also handle cache invalidation. Every deployment is a candidate for invalidation because the bundle could change at deployment, but not between.
We'll solve the caching using Redis. Since we have multiple nodes in our web app cluster local memory isn't an option. We'll solve the cache invalidation with a management command that runs at the end of each deployment. This uses a short stack (keeping only the latest n versions) instead of deleting.
We use a stack so we can push the new manifest to the top of the queue while leaving older references around. Requests to updated nodes can then fetch the latest bundle, while allowing older nodes to still work and serve up their existing (older) bundle. This enables random rolling upgrades on our cluster allowing us to push up updates in middle of a work day without disrupting end users.
All of this is done with basically a template tag python module and a management command.
Template Tag
We have this template tag module stored as vite.py, so that you can load it with {% load vite %} which then exposes the {% vite_styles %} and {% vite_scripts %} template tags.
import json
import re
import typing
from django import template
from django.conf import settings
from django.core.cache import cache
from django.templatetags.static import static
from django.utils.safestring import mark_safe
if typing.TYPE_CHECKING: # pragma: no cover
from django.utils.safestring import SafeString
ChunkType = typing.TypedDict("chunk", {"file": str, "css": str, "imports": list[str]})
ManifestType = typing.Mapping[str, ChunkType]
ScriptsStylesType = typing.Tuple[list[str], list[str]]
DEV_SERVER_ROOT = "http://localhost:3001/static"
register = template.Library()
def is_absolute_url(url: str) -> bool:
return re.match("^https?://", url) is not None
def set_manifest() -> "ManifestType":
with open(settings.MANIFEST_LOADER["output_path"]) as fp:
manifest: "ManifestType" = json.load(fp)
cache.set(settings.MANIFEST_LOADER["cache_key"], manifest, None)
return manifest
def get_manifest() -> "ManifestType":
if manifest := cache.get(settings.MANIFEST_LOADER["cache_key"]):
if settings.MANIFEST_LOADER["cache"]:
return manifest
return set_manifest()
def vite_manifest(entries_names: typing.Sequence[str]) -> "ScriptsStylesType":
if settings.DEBUG:
scripts = [f"{DEV_SERVER_ROOT}/@vite/client"] + [
f"{DEV_SERVER_ROOT}/{name}"
for name in entries_names
]
styles = []
return scripts, styles
manifest = get_manifest()
_processed = set()
def _process_entries(names: typing.Sequence[str]) -> "ScriptsStylesType":
scripts = []
styles = []
for name in names:
if name in _processed:
continue
chunk = manifest[name]
import_scripts, import_styles = _process_entries(chunk.get("imports", []))
scripts.extend(import_scripts)
styles.extend(import_styles)
scripts.append(chunk["file"])
styles.extend(chunk.get("css", []))
_processed.add(name)
return scripts, styles
return _process_entries(entries_names)
@register.simple_tag(name="vite_styles")
def vite_styles(*entries_names: str) -> "SafeString":
_, styles = vite_manifest(entries_names)
styles = map(lambda href: href if is_absolute_url(href) else static(href), styles)
return mark_safe("\n".join(map(lambda href: f'<link rel="stylesheet" href="{href}" />', styles))) # nosec
@register.simple_tag(name="vite_scripts")
def vite_scripts(*entries_names: str) -> "SafeString":
scripts, _ = vite_manifest(entries_names)
scripts = map(lambda src: src if is_absolute_url(src) else static(src), scripts)
return mark_safe("\n".join(map(lambda src: f'<script type="module" src="{src}"></script>', scripts))) # nosec
Here are a few features this supports::
- If running in local development, it will bypass loading from the manifest and load the
@vite/clientand point to the dev server that is running in a docker compose instance so we get HMR (Hot Module Replacement). - It relies on some settings that control if caching is enabled, what the cache key is (we set it to the
RELEASE_VERSIONwhich is pulled from the environment and tied to the git sha or tag. - We leverage the Django cache backend here for getting from and setting to the cache independent on what the actual cache backend is. This layer of indirection only works for this tag though and not for our cache invalidation management command.
The settings we use:
MANIFEST_LOADER = {
"cache": not DEBUG,
"cache_key": f"vite_manifest:{RELEASE_VERSION}",
"output_path": f"{STATIC_ROOT}/.vite/manifest.json",
}The management command gets a bit fancy with invalidation mainly to support running a multi-node cluster.
If you run a single web instance this probably isn't a lot of benefit.
However, we encountered issues spinning up additional nodes: some were updated, others weren’t, and we were seeing 500 errors during deployment because we needed to support both versions in the cache.
Our short term solution was to just put entire site into maintenance mode during deploys, but that's kind of annoying for pushing out some simple fixes. This technique has solved that for us with this management command that lives in post_deploy.py
from django.conf import settings
from django.core.cache import cache
from django.core.management import BaseCommand
from redis.exceptions import RedisError
from ...templatetags.vite import set_manifest
class Command(BaseCommand):
def success(self, message: str):
self.stdout.write(self.style.SUCCESS(message))
def warning(self, message: str):
self.stdout.write(self.style.WARNING(message))
def error(self, message: str):
self.stdout.write(self.style.ERROR(message))
def set_new_manifest_in_cache(self):
current_version = settings.RELEASE_VERSION
if not current_version:
self.warning(
"RELEASE_VERSION is empty; skipping cleanup to avoid deleting default keys."
)
return
prefix = "vite_manifest:*" # Match all versionsed keys
recent_versions_key = "recent-manifest-versions" # Redis key for tracking versions
try:
redis_client = cache._client.get_client()
# Add current version to the front of the list (in bytes)
redis_client.lpush(recent_versions_key, current_version.encode("utf-8"))
# Keep only the last 5 versions
redis_client.ltrim(recent_versions_key, 0, 5)
# Get recent versions as a set for quick lookup (decoding to strings)
recent_versions = {
v.decode("utf-8")
for v in redis_client.lrange(recent_versions_key, 0, -1)
}
self.success(f"Recent versions: {recent_versions}")
cursor = "0"
deleted_count = 0
while cursor != 0:
cursor, keys = redis_client.scan(cursor=cursor, match=prefix, count=100) # Batch scan
for key in keys:
key_str = key.decode("utf-8")
self.success(f"Checking key: {key_str}")
# If the key's version is not in recent versions, delete it
if not any(key_str.endswith(f":{version}") for version in recent_versions):
redis_client.delete(key)
deleted_count += 1
self.success(f"Deleted old manifest cache key: {key_str}")
self.success(
f"Added current version '{current_version}' and deleted {deleted_count} old manifest cache keys."
)
set_manifest()
self.success("Updated Vite manifest in cache.")
except RedisError as e:
self.error(f"Redis error: {e}")
def handle(self, *args, **options):
self.set_new_manifest_in_cache()
This isn't the prettiest code. We could probably tidy it up by extracting the Redis operations and/or the main while loop to make things more readable. But for now it's working and we haven't had to touch it in a while.
The latest six versions in our cache:

We had to break out of the pure django cache backend here to get access to some redis specific operations for the stack operations. Again, this is something that might be worth tidying up if we build a cache backend for django-vite but maybe not necessary if we build a Redis specific backend.
Not only do we invalidate the latest cache by pushing the version key down the stack, but we then seed the cache with the current version to save some time on a lazy load.
Summary
Next up is for us to take a hard look at django-vite as this seems to be a well structured and maintained project. Perhaps we can move to using this, retire our custom code, and then contribute what remains lacking either to the project or via a sidecar package.
Have you dealt with these problems in a different way? If so, we'd love to hear from you and learn about your approach.