Building web apps with Vue and Django (2024) - The Ultimate Guide

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.

1 server or 2 servers?

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.

Diagram: 1-server vs 2-server setup

Factors in favor of a 1-server setup:

  • Operational maintenance costs are significantly simplified with only a single server to deploy, monitor, and manage.
  • Relegating Django’s role to only that of an API server - in the 2-server setup - will complicate or even prevent you from using any Django features or third-party apps that rely on server-side rendering by Django, such as its famous built-in administration interface. Instead you’ll be doing most of your server-side rendering with some JavaScript framework that is Node-compatible. And Node-based server-side rendering is generally rather complex to setup.

Factors in favor of a 2-server setup:

  • If you want to use the latest and greatest version of JavaScript, with transpilation and module bundling, the 2-server setup has great community documentation for setting up a frontend server and build pipeline that supports all of these new JavaScript features.
    • However most of those features can still be obtained in a single-server setup with some effort, as described later in this article.
  • If your organization already has separate frontend and backend teams, it may be easier to have 2 servers - one managed by the frontend team, and one managed by the backend team - to allow each team to focus on each server separately (and satisfy Conway’s Law 1 ๐Ÿ™‚).
  • If your organization already has a separate operations team that has the capacity to manage the additional server implied by a 2-server setup, then the additional operational overhead may be acceptable.

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.

1-server approach

When using a single server, you’ll need to:

Bundling strategies

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:

Concatenated Bundling

Diagram: Concatenated Bundling Strategy

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:

  • Very easy to setup. Leverages excellent documentation from the Django Compressor library.
  • Zero bundle build times during development.
  • Fastest bundle build times during deployment, compared with other approaches.2

Cons of concatenated bundling:

  • You must manage your JS dependencies in HTML manually:
    • Whenever adding a new JS module you must remember to add it to the HTML for the appropriate page(s).
    • If you alter an existing JS module to depend on a new JS module, you must find all page HTMLs that include the first module and update them to include the second module if needed.
    • If you want to avoid managing JS dependencies manually, consider import-traced bundling instead.
  • JavaScript files are not transformed or transpiled in any way, so if you want to use newer JavaScript features that aren’t supported by your customers' browsers then you’re out of luck. If you need transpilation consider the transpiled or the 2-server approaches instead.
  • It is awkward to define a class in a JavaScript module that inherits from a base class in a different module, because that requires <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).

Import-Traced Bundling

Diagram: Import-Traced Bundling Strategy

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 root static directory in vite.config.js.

Some key differences between import-traced bundling and concatenated bundling:

  • The HTML page only needs to include the root JS file for the page and not any of its indirect JS dependencies.
  • JS modules must use import to declare other JS modules that they depend on, and export any functions that they want to be importable by other modules.
  • JS files for all Django apps in the Django project should be put in a common 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.
    • It should still be possible to configure Vite to use app-specific static directories, with some custom build system modifications, but I haven’t yet tried to do so myself.
  • Django Compressor is no longer necessary.

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:

  • Easy to setup for development. (Trickier to setup for deployment.)
  • Zero bundle build times during development.
  • Fast bundle build times during deployment.
  • JS dependencies are automatically managed through regular import statements in JS.

Cons of import-traced bundling:

  • JavaScript files are not transpiled at development time (although they are at deployment time), so if you want to use the most bleeding-edge JavaScript features that aren’t even supported by your development browser then you’re out of luck. If you need transpilation during development consider the transpiled or the 2-server approaches instead.

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.

Transpiled Bundling

Diagram: Transpiled Bundling Strategy

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:

  • Latest bleeding-edge JavaScript features are available.
  • JS dependencies are automatically managed.

Cons of transpiled bundling:

  • Moderate-to-slow bundle build times during development.
  • Slow bundle build times during deployment, assuming you enable aggressive optimizations.

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.

Render baseline HTML with Django

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:

  • the site logo and top navigation links,
  • the product image,
  • the product description,
  • placeholders for other less-important panels that are okay to load later in JavaScript, with appropriate animated spinner icons or other loading indicators.

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:

Enhance baseline HTML with Vue

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 via document.querySelector(...).innerText in JavaScript.

Done! Skip to the conclusion.

2-server approach

When using a separate frontend server for Vue and a separate backend server for Django, you’ll need to:

Conclusion

Hopefully this guide has been useful in helping you setup Vue inside your new or existing Django web app. Happy coding!

Related Articles

  • Database clamps - Writing deterministic performance tests for database-dependent code in Django
  • Tests as Policy Automation - Has ideas for creatively using automated tests to enforce various (non-functional) properties in your Django web app.
  • Other Django6 articles

Related Projects

  • TechSmart Platform - Large web app that I work on that uses Django and Vue. (Sorry itโ€™s closed-source!)

  1. 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.

  2. 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!).