For the last decade and more, we've been bundling CSS and JavaScript files. These build tools allowed us to utilize new browser capabilities in CSS and JS while still supporting older browsers. They also helped with client-side network performance, minimizing the content to be as small as possible and combining files into one large bundle to reduce network handshakes. We've gone through a lot of build tools iterations in the process; from Grunt (2012) to Gulp (2013) to Webpack (2014) to Parcel (2017) to esbuild (2020) and Vite (2020).
And with modern browser technologies there is less need for these build tools.
- Modern CSS supports many of the features natively that the build tools were created for. to organize code, , for feature detection.
- JavaScript ES6 / ES2015 was a big step forward, and the language has been progressing steadily ever since. It now has native module support with the / keywords
- Meanwhile, with , parallel requests can be made over the same connection, removing the constraints of the HTTP/1.x protocol.
These build processes are complex, particularly for beginners to Django. The tools and associated best practices move quickly. There is a lot to learn and you need to understand how to utilize them with your Django project. You can build a workflow that stores the build results in your static folder, but there is no core Django support for a build pipeline, so this largely requires selecting from a number of third party packages and integrating them into your project.
The benefit this complexity adds is no longer as clear cut, especially for beginners. There are still advantages to build tools, but you can can create professional results without having to use or learn any build processes.
Build-free JavaScript tutorial
To demonstrate modern capabilities, let's expand with some newer JavaScript. We’ll use modern JS modules and we won’t require a build system.
To give us a reason to need JS let's add a new requirement to the polls; to allow our users to add their own suggestions, instead of only being able to vote on the existing options. We update our form to have a new option under the selection code:
or add your own <input type="text" name="choice_text" maxlength="200" />
Now our users can add their own options to polls if the existing ones don't fit. We can update our voting view to handle this new option, with more validation:
- If there is no vote selection we handle adding the new option
- If there is no vote selection nor a new
choice_text, we show an error
- Also show an error if both are selected.
With our logic getting more complex it would be nicer if we had some JavaScript to do this. We can build a script that handles some of the form validation for us.
// Note the "export default" to make this function available for other modules.
export default function initFormValidation() {
document.getElementById("polls").addEventListener("submit", function (e) {
const choices = this.querySelectorAll('input[name="choice"]');
const choiceText = this.querySelector('input[name="choice_text"]');
const hasChecked = [...choices].some(r => r.checked);
const hasText = choiceText?.value.trim() !== "";
if (!hasChecked && !hasText) {
e.preventDefault();
alert("You didn't select a choice or provide a new one.");
}
if (hasChecked && hasText) {
e.preventDefault();
alert("You can't select a choice and also provide a new option.");
}
});
}
Note how we use
export default in the above code. This means
form_validation.js is a JavaScript module. When we create our
main.js file, we can import it with the
import statement:
import initFormValidation from "./form_validation.js";
initFormValidation();
Lastly, we add the script to the bottom of our
details.html file, using Django’s usual
static template tag. Note the
type="module" this is needed to tell the browser we will be using import/export statements.
<script type="module" src="{% static 'polls/js/main.js' %}"></script>
That’s it! We got the modularity benefits of modern JavaScript without needing any build process. The browser handles the module loading for us. And thanks to parallel requests since HTTP/2, this can scale to many modules without a performance hit.
In production
To deploy, all we need is Django's support for collecting static files into one place and its support for adding hashes to filenames. In production it is a good idea to use storage backend. It stores the file names it handles by appending the MD5 hash of the file’s content to the filename. This allows you to set far future cache expiries, which is good for performance, while still guaranteeing new versions of the file will make it to users’ browsers.
This backend is also able to update the reference to
form_validation.js in the import statement, with its new versioned file name.
Future work
ManifestStaticFilesStorage works, but a lot of its implementation details get in the way. It could be easier to use as a developer.
- The support for
import/export with hashed files is not very robust.
- Comments in CSS with references to files can .
- Circular dependencies in CSS/JS can not be processed.
- Errors during collectstatic when files are missing are not always clear.
- Differences between implementation of StaticFilesStorage and ManifestStaticFilesStorage can lead to errors in production that don't show up in development (like ).
- Configuring common options means subclassing the storage when we could use the existing OPTIONS dict.
- Collecting static files could be faster if it used parallelization (pull request: )
We discussed those possible improvements at the sprints and I’m hopeful we can make progress.
I built to attempt to fix all these. The core work is to switch to a lexer for CSS and JS, that was used in Django previously. It was expanded to cover modern JS and CSS by working with Claude Code to do the grunt work of covering the syntax.
It also switches to using a topological sort to find dependencies, whereas before we used a more brute force approach of repeated processing until we saw no more changes, which lead to more work, particularly on storages that used the network. It also meant we couldn't handle circular dependencies.
To validate it works, I ran a , it’s been tested issues and with similar (often improved) performance. On average, it’s about 30% faster.
While those improvements would be welcome, do go ahead with trying build-free JavaScript and CSS in your Django projects today! Modern browsers make it possible to create great frontend experiences without the complexity.