Vue and Django are both fantastic for building modern web apps - bringing declarative functional reactive programming to the frontend, and an integrated web app platform, ecosystem, and battle-hardened ORM to the backend. However they can be somewhat tricky to use together.
Here I’d like to show some approaches for setting Vue and Django up in combination for both new web apps and existing Django-based web apps. I’ve been building web apps with Django for ~9 years and with Vue for ~6 years, and in particular I’ve extensively tested the 1‑server concatenated bundling approach described below in production.
The first question to consider when planning to use Django and Vue together is whether to use a single server that serves both backend endpoints and frontend assets, or to use two different servers, one to host the frontend and a separate “API server” to host the backend.
Factors in favor of a 1-server setup:
Factors in favor of a 2-server setup:
At my company we went with the single server option because our engineering team is small (< 5 engineers) - so we care a lot about minimizing operational overhead - and is composed of full-stack generalists who are familar with both backend and frontend technologies - so we have engineers that can work anywhere in the stack but aggressively prefer simple architechures that don’t require extreme specialization in multiple domains.
In the next section I’ll drill down into the 1-server approach, but you can also skip to the 2-server approach if that’s what you’re leaning toward.
When using a single server, you’ll need to:
When using a single integrated Django server to not only host your backend but also serve your frontend, you’ll need to decide how you want to bundle your JavaScript assets on the frontend together. Bundling of some kind is necessary for a production deployment that is fast enough to be mobile-friendly and usable by distant international customers who may not have fast network connectivity to your server.
There are multiple bundling techniques that can be used with Django, with various pros and cons:
This is by far the easiest bundling technique to use with Django and is a good technique to start with.
With concatenated bundling, you write JavaScript files that consist entirely of top-level function definitions and other non-executable code. Your HTML template served by Django contains <script>
-includes of every JavaScript file that the current page requires, directly or transitively. And after all JavaScript files are included the HTML page uses an inline <script>
to call the root JavaScript function which sets up the rest of the page:
<!-- todo/templates/todo/todo.html -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>...</body>
{% compress js %}
<script src="{% static 'todo/todo.js' %}"></script>
<script src="{% static 'todo/todo/list.js' %}"></script>
<script src="{% static 'todo/todo/item.js' %}"></script>
<script>
setupTodoPage();
</script>
{% endcompress %}
</html>
Note: The
{% compress js %}
tag above assumes that you are using the excellent Django Compressor library which integrates well with Django as your concatenating bundler.
Pros of concatenated bundling:
Cons of concatenated bundling:
<script>
-including the base class’s module first, breaking the usual rule that “the order that JS files are imported should not matter”. (However if you just limit inheritance to be within a single module or avoid it entirely, then this restriction doesn’t matter in practice.)Note that choosing concatenated bundling does not prevent you from using TypeScript-based type checking in your JS files, which I do recommend for long-lived projects. In short, you can put a special // @ts-check
comment at the top of a JS file to enable type-checking of JS with the TypeScript compiler (tsc
).
With the advent of the Vite bundler we can get all the benefits of the concatenated bundling approach while eliminating the need to manage JS dependencies manually, at the cost of a slightly-more-complex deployment process.
With import-traced bundling, you write a root JavaScript file for each page that uses regular JavaScript import statements to bring in related modules. Your HTML template then only needs to include the root JavaScript file:
<!-- todo/templates/todo/todo.html -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>...</body>
<!-- js -->
<script type="module">
import { setupTodoPage } from "@app/todo/todo.js";
setupTodoPage();
</script>
<!-- endjs -->
</html>
The root JavaScript file might look like:
// static/todo/todo.js
import { defineTodoList } from "@app/todo/list.js";
export function setupTodoPage() {
const app = Vue.createApp(...);
defineTodoList(app);
app.mount(...);
}
And that root JS file can include other modules like:
// static/todo/list.js
import { defineTodoItem } from "@app/todo/item.js";
export function defineTodoList(app) {
app.component('todo-list', {
props: {
...
},
template: `
...
`,
methods: {
...
}
});
defineTodoItem(app);
}
Note: The
import
statements above assume that Vite has been configured to resolve@app
to Django’s rootstatic
directory invite.config.js
.
Some key differences between import-traced bundling and concatenated bundling:
import
to declare other JS modules that they depend on, and export
any functions that they want to be importable by other modules.static
directory rather than using per-app static
directories. Having a common directory for all JS files will make it easier to configure Vite to build combined JS bundles for production deployments.
static
directories, with some custom build system modifications, but I haven’t yet tried to do so myself.For a production deployment, you’ll need to alter your deployment script to run Vite on the root static
directory for the Django project, enumerating the set of root JS files, and generating same-named files for deployment to your static asset server.
Pros of import-traced bundling:
import
statements in JS.Cons of import-traced bundling:
To see a full example of combining Django and Vite together using the above strategy, take a look at the hello-django-vite prototype I put together.
2024 Update: The import-traced bundling approach used with newer bundlers like Vite gives all the advantages of transpiled bundling without any of the downsides. Therefore I would not recommend a traditional transpiler approach in 2024.
If you want the latest and greatest JavaScript features that haven’t made it even to your latest development browser (ex: the latest Chrome or Firefox) then you’ll need to pay the cost of needing to transpile during development time:
The transpiled bundling approach generally uses the same kind of HTML, JS, and filesystem structure as the import-traced bundling approach but you have additional flexibility depending on the particular set of bundler and transpiler tools you select.
Common choices for transpiler tools as of mid 2024 are Vite/Rollup, Webpack, Babel, and TypeScript.
Pros of transpiled bundling:
Cons of transpiled bundling:
For a sketch of how you might wire these tools together, take a look at Jacob Kaplan-Moss’s thoughts on the transpiled bundling approach at PyCon 2019.
Now that you’ve picked a bundling approach, we can move on to rendering our first page with Django and Vue.
When a browser (or search engine crawler) first requests a page from your site, it will only be able to immediately render the initial HTML served by Django. In particular, browsers will take some time to start running any JavaScript on your HTML page, so it’s important that the initial HTML served by Django contains your most important page content.
For example if we were building the product page of an online store, we’d want to ensure the initial HTML rendered by Django immediately contained things like:
So the HTML rendered by Django might look something like:
<!-- store/templates/store/product.html -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<!-- Top navigation -->
<nav class="navbar navbar-inverse">
<div class="container-fluid">
<div class="navbar-header">
<!-- Site logo -->
<a class="navbar-brand" href="/">Acme Store</a>
</div>
<div class="navbar-collapse collapse">
<!-- Top navigation links -->
<ul class="nav navbar-nav">
<li><a href="/computer-systems/">Computer Systems</a></li>
<li><a href="/components/">Components</a></li>
<li><a href="/electronics/">Electronics</a></li>
...
</ul>
</div>
</div>
</nav>
<!-- Main content -->
<div class="container">
<div class="content-container">
<!-- Primary panel -->
<img alt="Product image" href="{{ product.image_url }}" style="float: left;" />
<h1>{{ product.title }}</h1>
<div>
{{ product.description }}
</div>
<!-- Secondary panel; a placeholder filled out by JS later -->
<div id="recommendations-panel">
<h2>Recommended for You</h2>
<img
v-if="loading"
alt="Loading spinner"
src="{% static 'store/loading-spinner.gif' %}" />
<template
v-else
v-cloak>
...
</template>
</div>
</div>
</div>
</body>
{{ recommendations_panel_data|json_script:"recommendations-panel-data" }}
{% compress js %}
<script src="{% static 'store/product.js' %}"></script>
<script src="{% static 'store/product/recommendations.js' %}"></script>
<script>
setupProductPage();
</script>
{% endcompress %}
</html>
Notice that most of the page is initially rendered server-side with Django’s built-in templating system and not with Vue. This server-side rendered content loads fast and can be appropriately indexed by search engines for better SEO.
However certain less-important panels like the Recommendations panel don’t need to be loaded immediately and will be given life by Vue later after the page’s JavaScript starts running. Let’s consider how that works:
When the browser (or search engine) first loads the Recommendations panel it will initially see just a loading spinner:
<div id="recommendations-panel">
<h2>Recommended for You</h2>
<img
v-if="loading"
alt="Loading spinner"
src="{% static 'store/loading-spinner.gif' %}" />
<template
v-else
v-cloak>
...
</template>
</div>
Note: The
v-cloak
directive is associated with the CSS rule[v-cloak] { display: none; }
so nothing marked by that directive will be displayed initially.
Later when the page’s JavaScript starts running, it will call setupProductPage()
:
// store/product.js
/*public*/ function setupProductPage() {
setupRecommendationsPanel();
}
which will call setupRecommendationsPanel()
:
// store/product/recommendations.js
/*public*/ function setupRecommendationsPanel() {
Vue.createApp({
data() {
return JSON.parse(document.querySelector('#recommendations-panel-data').innerText);
},
computed: {
loading() {
...
},
...
},
methods: {
...
}
}).mount('#recommendations-panel');
}
which will use Vue to render the interior of the panel, perhaps after performing an Ajax request back to a Django endpoint to fetch more data.
Notice that some data for the panel can be prepopulated in HTML via the
{{ ...|json_script:"..." }}
template tag by Django and then fetched later viadocument.querySelector(...).innerText
in JavaScript.
Done! Skip to the conclusion.
When using a separate frontend server for Vue and a separate backend server for Django, you’ll need to:
Hopefully this guide has been useful in helping you setup Vue inside your new or existing Django web app. Happy coding!
Conway’s Law: The technical architecture of a system tends to mirror the organizational and communication structure of the people that build it. So if you have 2 teams building a compiler, they’re likely to build a 2-pass compiler. Similarly if you have a frontend and a backend team, they’re likely to build separate frontend and backend servers if left to themselves.↩
At the time of writing it takes 2.1 seconds for me to bundle 100,650 lines (5,304 KiB) of JS and 17,617 lines (699 KiB) of CSS using Django Compressor’s default settings which uses rJSMin to minify JS (via regex no less!).↩