How to Improve Django DX Using webpack

Thu Nov 16 2023

Logos for both Django and webpack

Table of Contents

Introduction

Recently, the Web Development Industry Pendulum has started to swing away from the churn of newer, faster, lighter, better, more JavaScriptier front-end frameworks. There seems to be a resurgence of interest in more traditional server-side rendered (SSR) multi-page applications (MPAs). It feels a bit funny to write out “multi-page applications” when the term wouldn’t really exist except to differentiate them from single-page applications (SPAs). To me, MPAs are the norm—the original things for which the word “website” was made to describe.

To be clear, I don’t dislike SPAs or front-end JavaScript frameworks. I love JavaScript/TypeScript and developing applications with React. However, I also recognize that—as a solo developer working on very small-scale projects—a decoupled React + back-end API structure is almost always overkill in both functionality and complexity.

For the vast majority of my projects, I’m perfectly happy using Django, despite it being 20 years old and the fact that it’s written in a relatively slow language. When I eventually do create the world’s most popular and interactive web application that needs to handle millions of concurrent users, I suppose I will consider using a different framework.

Anyway, where am I going with this? If there’s one aspect of newer frameworks that I have fallen head-over-heels in love with, it is the development experience. The following is my attempt to incorporate some of the newer advancements in web development into my Django workflow.

What is Django?

Django is a “high-level Python web framework” that enables developers to create websites quickly by handling a lot of common patterns out of the box. In my humble opinion, the Django documentation is the gold standard, the benchmark against which I measure all other documentation.

What is webpack?

Webpack is a module bundler. It allows developers to separate and organize assets in whatever way suits their local development environment. Developers configure webpack to build and bundle these assets in a way that best suits their deployment situation. It’s complicated, but has decent documentation. There are a host of alternatives, none of which I’m particularly familiar with.

Why use them together?

Webpack has a ton of functionality, but truthfully, I only want to use a few features. For example, I don’t anticipate my scripts growing to the point where I need to be concerned about code splitting, but I definitely do want to be able to use tools like PostCSS.

I made the following outline of things I want the ability to do:

  1. Use TypeScript.
  2. Use PostCSS, Sass, TailwindCSS, or some combination of the three.
  3. Enable automatic browser reloads.

I made a choice incorporate webpack without the use of a Django plugin like Django Webpack Loader because I only wanted to add the bare minimum and I wanted to understand 100% of what I was doing. If you do need additional functionality like code splitting, I encourage you to check that project out. It seems very well liked and very well maintained.

Tutorial

I’m going to walk through the process of setting up a Django/webpack development environment. My goal is to be thorough enough that a Django beginner could follow along, but not so thorough as to be a replacement for the official Django tutorial.

Note: when linking to django documentation, you can replace the version number with the word ‘stable’ to future-proof your link.

If you want to take a look at the finished code, checkout the repository on GitHub.

Getting Started

My operating system is Windows and I use PowerShell. While many of the commands will work on *nix machines, a few of them are PowerShell specific.

1. Create Project

Create and change into a new directory called webpack-django.

mkdir webpack-django && cd webpack-django

Create and activate a new virtual environment.

py -m venv .venv
.venv/Scripts/Activate.ps1

Upgrade pip and install Django.

py -m pip install --upgrade pip
pip install django

Use Django’s built-in CLI to scaffold a new project.

django-admin startproject config .

The config and . options are telling Django that I want to create a new project with the name “config” inside the current directory. “Config” sounds like a weird name for a project, and it is, but it’ll make more sense when you see the resulting directory structure.

└── webpack-django/
    ├── .venv/
    ├── config/
    │   ├── __init__.py
    │   ├── asgi.py
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    └── manage.py

By default, Django creates a directory with the same name as the project and stores all configuration files within it. If you work on a bunch of different Django projects, it becomes tedious to look for these configuration files in differently named directories, so it’s nice to pick a standard name and stick with it.


2. Create an App with a Simple View

Now that we’ve created a Django project, we’re not going to call the django-admin cli directly. Instead we’re going to run and pass arguments to the manage.py script as if it were django-admin. The main distinction is that manage.py “sets the DJANGO_SETTINGS_MODULE environment variable so that it points to your project’s settings.py file.” For more information, check the docs.

py manage.py startapp hello_webpack

This command creates an app directory called “hello_webpack” and populates it with some starter files. We need to tell Django that this app exists so that the code we write for it gets run. Add hello_webpack the the INSTALLED_APPS list within config/settings.py.

While we’re at it, let’s update the TEMPLATES list so Django knows to look for HTML templates inside a project-root-level directory called “templates.” We should also tell Django where to look for static assets. Make the following changes:

  # config/settings.py
  ...

  INSTALLED_APPS = [
      # other apps
+     "hello_webpack",
  ]

  ...

  TEMPLATES = [
      {
          "BACKEND": "django.template.backends.django.DjangoTemplates",
-         "DIRS": [],
+         "DIRS": [BASE_DIR / "templates"],
          "APP_DIRS": True,
          "OPTIONS": {
              "context_processors": [
                  "django.template.context_processors.debug",
                  "django.template.context_processors.request",
                  "django.contrib.auth.context_processors.auth",
                  "django.contrib.messages.context_processors.messages",
              ],
          },
      },
  ]

  ...

+ STATICFILES_DIRS = [BASE_DIR / "assets/build"]
+ STATIC_ROOT = BASE_DIR / "static"
  STATIC_URL = "static/"

Now let’s create the “templates” directory and create a new HTML template inside of it called base.html.

mkdir templates
New-Item templates/base.html

Copy the following code into base.html. This template contains the base information shared by all templates so that we don’t have to repeat ourselves. Check out the docs to learn more about Django templates.

Notice the fact that we’ve included links to two external files that don’t exist yet, main.css and main.js. We’ll get to creating them once we configure webpack.

{% load static %}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="{% static 'main.css' %}" />
    <title>{% block title %}Django|webpack{% endblock %}</title>
  </head>
  <body>
    {% block content %}{% endblock %}
    <script src="{% static 'main.js' %}"></script>
    {% block extra_js %}{% endblock %}
  </body>
</html>

Within the templates directory, create a sub-directory named hello_webpack. We’re following a standard Django naming convention here by creating a template sub-directory with the same name as our app. Any and all templates that belong to the hello_webpack app will go in this directory.

Let’s start with one called sample.html.

mkdir templates/hello_webpack
New-Item templates/hello_webpack/sample.html

The template extends base.html and adds a few things:

{% extends "base.html" %} {% load static %} {% block content %}
<main>
  <h1>This is a sample HTML page</h1>
  <p>Lorem ipsum something or other...</p>
  <p>Count: <span id="count-display">0</span></p>
  <button id="increment-btn">+1</button>
</main>
{% endblock %} {% block extra_js %}
<script src="{% static 'counter.js' %}"></script>
{% endblock %}

We’ve added a lot of new files and directories, so let’s take a moment to review our project’s directory structure:

└── webpack-django/
    ├── .venv/
    ├── config/
    │   ├── __init__.py
    │   ├── asgi.py
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    ├── hello_webpack/
    │   ├── __init__.py
    │   ├── admin.py
    │   ├── apps.py
    │   ├── models.py
    │   ├── tests.py
    │   └── views.py
    ├── templates/
    │   ├── hello_webpack/
    │   │   └── sample.html
    │   └── base.html
    └── manage.py

The next step is to make our sample.html template accessible from a url on our local machine. Copy the following code into hello_webpack/views.py.

from django.shortcuts import render

def hello(request):
    template = "hello_webpack/sample.html"
    return render(request, template)

Next it’s time to register this view function with the project’s url routing. Make the following edits to config/urls.py.

  from django.contrib import admin
  from django.urls import path

+ from hello_webpack.views import hello

  urlpatterns = [
      path("admin/", admin.site.urls),
+     path("", hello),
  ]

Setting the first parameter of path to an empty string means that our view function will be called when someone visits the base URL of our site—in our case http://localhost:8000/ or http://127.0.0.1:8000/.

Run the following commands to (1.) create and run database migrations and create db.sqlite3, and (2.) run Django’s built-in development server.

py manage.py migrate
py manage.py runserver

Visit localhost:8000 to see the super simple HTML we wrote. Right now there are no styles applied and the increment button does nothing. In the next section, we’re going to do something about that. Feel free to stop the server at any time by typing Ctrl+C.


Configuring webpack

If you haven’t downloaded and installed Node.js, take a moment to do that now.

1. Initialize NPM and Install Dependencies

First things first, create a package.json file.

npm init -y

This command should create the following .json file.

{
  "name": "webpack-django",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Now, if you’ve never configured webpack, the quantity of npm packages may seem intimidating, but I’ll go through each one. There’s no reason why you can’t install all of these dev dependencies in one command, but I broke it up into categories to make it more readable.

1. Base webpack packages

npm i -D webpack webpack-cli

2. Webpack loaders & plugins

Loaders are used to preprocess various different types of files so that webpack can work with them. Plugins are tools that perform operations on preprocessed/loaded files.

npm i -D postcss-loader css-loader mini-css-extract-plugin ts-loader

3. PostCSS packages & plugins

While it is often mislabeled as such, PostCSS is neither a preprocessor nor a framework. What PostCSS does is transform CSS into a standard format called an Abstract Syntax Tree (AST), run that through a list of configured plugins that do some kind of work on the data, and then transform the data back into CSS.

npm i -D postcss autoprefixer postcss-import postcss-nested

4. TypeScript

TypeScript is a superset of JavaScript. The primary, titular feature that TypeScript brings to the table is syntax for strongly typed objects. I adore TypeScript, but I will admit that there are times in which debugging it feels a bit like trying to talk your paranoid IDE off the ledge. I still believe that it is absolutely worth learning.

npm i -D typescript

2. Create Config File

Create a file called webpack.config.js. These files have a tendency to get pretty complicated, so we’ll break it down into a few steps.

We’re essentially building up and exporting an object that will give webpack information about how to bundle our code and where to place the output. Copy the following into webpack.config.js.

const path = require("path");

module.exports = {
  entry: "./assets/src/main.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "assets/build"),
  },
};

This is about as simple as a webpack config file can be. Our object has two properties entry and output. The former tells webpack what file to read. Typically this file will import all other project files (if you’re familiar with React, think of index.js). The latter property tells webpack where to put the bundled code. The filename property sets the name for the output file, and the path property is the location where we want this file to end up. Here, we use the Node.js “path” module to specify the location relative to the webpack config file.


Configuring TypeScript

At this point, it’s time to consider what kind of structure we want our bundled/built code to have. If we were building a single-page application, our goal would be to minimize the overall number of JavaScript and CSS files by as much as possible—ideally to just one each. It’s a delicate balance of trying to speed up loading and limit HTTP requests while still taking advantage of browser caching.

However, since we are not building a single-page app, there are two major differences.

  1. Our website will likely have fewer JavaScript files.
  2. These files will almost certainly be less complex (and also smaller).

Based on these assumptions I want my build directory to look like this:

The first challenge this presents is that we’re going to have multiple entry files. Fortunately, the entry property in webpack accepts multiple files in multiple different ways. We could either pass an array of strings or an object with multiple key/value pairs. I prefer the latter options because it gives us more control over how our output files are named. We can take advantage of a webpack feature where we set output.filename: "[name].js".

Our entries object will look something like this:

...
module.exports = {
  entry: {
    main: ["./assets/src/main.ts", "./assets/src/main.css"],
    counter: "./assets/src/counter.ts",
    someOtherScript: "./assets/src/someOtherScript.ts",
  },
  ...
}

We could hard-code this object and update it every time we add a new file, but generating this object programmatically is more convenient. To do this, let’s use another npm package, glob.

npm i -D glob

Now, let’s create the following function and add it to our webpack config file. Notice the file extensions here are .ts because our input files will all be written in TypeScript.

const { globSync } = require("glob");

const getEntries = () => {
  const entries = {};
  /**
   * Grab all TypeScript files from within
   * src/ or any sub-directory of src/
   */
  const tsFiles = globSync("./assets/src/**/*.ts");

  for (const file of tsFiles) {
    // Get just the filename without the extension
    const filename = path.basename(file, ".ts");
    // Add key/value pair to entries object
    entries[filename] = `./${file}`;
  }

  // Get path to main.css
  const mainCss = "./assets/src/main.css";
  const main = entries?.main;

  // Check whether or not there is a main.js in entries
  if (!main) {
    // if not, set it equal to path string
    entries.main = mainCss;
  } else {
    /**
     * if entries.main is set to a single string
     * create an array from the existing string
     * and main.css
     */
    entries.main = [main, mainCss];
  }

  return entries;
};

Now that webpack knows which files to work with, let’s give it some instructions on how to work with them. Let’s start with the TypeScript files. Make the following changes to the webpack.config.js file.

  module.exports = {
-   entry: "./assets/src/main.js",
+   entry: getEntries(),
    output: {
-     filename: "main.js",
+     filename: "[name].js",
      path: path.resolve(__dirname, "assets/build"),
    },
+   module: {
+     rules: [
+       {
+         test: /\.ts$/i,
+         use: "ts-loader",
+         exclude: "/node_modules/",
+       },
+     ],
+   },
  };

In this context, modules are different kinds of files that webpack can interface with. This page is a good resource for working with modules. The test property is set to a regex that matches a specific type of file within our entry object. The use property is usually set to an array of loaders that are executed in reverse order. In our case, there’s only one loader, so a string is fine. The exclude property does exactly what it sounds like. Based on our getEntries() function, there shouldn’t be any reason why webpack would look through our node_modules/ directory, but considering how huge those can get, why not make doubly sure webpack can’t mess with it.

One last step in order to get TypeScript set up: create a tsconfig.json file. Honestly, this file can be incredibly intimidating. One way of generating this file is by running the command:

tsc --init

I encourage you to poke around the file that this command produces as well as read the documentation, but for now, it’s enough to manually create this file and copy the following code:

{
  "compilerOptions": {
    "outDir": "./dist",
    "noImplicitAny": true,
    "module": "es6",
    "target": "es6",
    "allowJs": true,
    "strict": true,
    "moduleResolution": "node"
  }
}

With all this setup, let’s write the script that will make our increment button work! Create an assets/src/ directory and a counter.ts file inside it.

mkdir -p assets/src
New-Item assets/src/counter.ts

Copy and paste this code into counter.js.

const countDisplay = document.querySelector<HTMLSpanElement>("#count-display")!;
const incrementBtn =
  document.querySelector<HTMLButtonElement>("#increment-btn")!;

incrementBtn.addEventListener("click", () => {
  const current = Number(countDisplay.textContent);
  countDisplay.textContent = String(current + 1);
});

Configuring PostCSS

Next let’s tackle styling. We’ll need to add another object to the modules.rules array in our webpack.config.js file that will match CSS files. We’ll also need to include a plugin called mini-css-extract-plugin which does exactly what it says it does: it extracts parsed and loaded CSS into its own file. Make the following changes to webpack.config.js:

  const path = require("path");
  const { globSync } = require("glob");
+ const MiniCssExtractPlugin = require("mini-css-extract-plugin");

  const getEntries = () => {
    const entries = {};
    const tsFiles = globSync("./assets/src/**/*.ts");

    for (const file of tsFiles) {
      const filename = path.basename(file, ".ts");
      entries[filename] = `./${file}`;
    }

    const mainCss = "./assets/src/main.css";
    const main = entries?.main;

    if (!main) {
      entries.main = mainCss;
    } else {
      entries.main = [main, mainCss];
    }

    return entries;
  };

  module.exports = {
    entry: getEntries(),
    output: {
      filename: "[name].js",
      path: path.resolve(__dirname, "assets/build"),
    },
+   plugins: [
+     new MiniCssExtractPlugin({
+       filename: "[name].css",
+     }),
+   ],
    module: {
      rules: [
        {
          test: /\.ts$/i,
          use: "ts-loader",
          exclude: "/node_modules/",
        },
+       {
+         test: /\.css$/i,
+         use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
+       },
      ],
    },
  };

Just like how when we added ts-loader we needed to create a tsconfig.json file, now that we’ve added postcss-loader we need to create a postcss.config.js file. This config file, however, is much less intimidating.

New-Item postcss.config.js

Add the following code:

module.exports = {
  plugins: [
    require("postcss-import"),
    require("autoprefixer"),
    require("postcss-nested"),
  ],
};

Unlike arrays of webpack loaders, the plugins array is executed left-to-right, top-to-bottom the way one would expect. And so, we’ll arrange our plugins in the order in which we want them to be applied.

Now let’s write some contrived css that utilizes the plugins we chose to use…

Create a main.css file inside the assets/src folder. Then create a sub-directory assets/src/css and add the file sample.css to that.

New-Item assets/src/main.css
mkdir assets/src/css
New-Item assets/src/css/sample.css

For now the only code in main.css will be an @import statement that imports sample.css.

/* postcss-import with inline the code from this file */
@import "./css/sample.css";

Copy and paste the following into sample.css:

:root {
  --cornflowerblue: 100, 149, 237;
}

body {
  font-family: sans-serif;
  font-size: 18px;
}

main {
  margin: auto;
  width: min(700px, 100%);

  /* postcss-nested will unwrap these nested declarations */
  h1 {
    text-align: center;
    color: darkslategray;
    /* autoprefixer will add vendor prefixes to this rule */
    user-select: none;
  }

  p {
    font-family: Georgia, "Times New Roman", Times, serif;
  }

  #count-display {
    padding: 0.25rem 0.5rem;
    font-family: monospace;
    background-color: rgba(var(--cornflowerblue), 0.3);
    border-radius: 0.25rem;
  }

  #increment-btn {
    padding: 0.25rem 0.5rem;
    font-size: 1rem;
  }
}

Webpack Building Scripts

Now run this command which will take our code, process and bundle it, and dump it into assets/build/.

npx webpack

If all goes well, you should see a new directory assets/build with three files in it:

One of the primary benefits I’m looking to get out of using webpack and Django together is the automation of static file bundling. And having to run a command every time I update my code is not exactly helping with that goal. We’re going to use a webpack feature called watch. When we use this command, webpack watches our static files for any changes and every time we save a new change, webpack re-bundles the asset so that the assets/build directory always contains the most up-to-date code.

One way of using this feature is by running this command:

npx webpack --watch

But because we’re already using NPM, I’d prefer to keep all of my scripts inside my package.json file. Make the following changes:

  {
    "name": "django-webpack-example",
    "version": "1.0.0",
    "description": "",
-   "main": "index.js",
    "scripts": {
-     "test": "echo \"Error: no test specified\" && exit 1"
+     "watch": "webpack --watch",
+     "build": "webpack"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "devDependencies": {
      "autoprefixer": "^10.4.16",
      "css-loader": "^6.8.1",
      "glob": "^10.3.10",
      "mini-css-extract-plugin": "^2.7.6",
      "postcss": "^8.4.31",
      "postcss-import": "^15.1.0",
      "postcss-loader": "^7.3.3",
      "postcss-nested": "^6.0.1",
      "ts-loader": "^9.5.1",
      "typescript": "^5.2.2",
      "webpack": "^5.89.0",
      "webpack-cli": "^5.1.4"
    }
  }

It isn’t strictly necessary to delete those two lines, but they aren’t needed. Now whenever we want to watch or build our static files, we can run one of the following commands:

# start watching for changes
npm run watch

# one-time build process
npm run build

Configuring Django Browser Reload

It’s time to see our static assets in action! In one PowerShell/terminal window, run the following command:

npm run watch

Open a new window, navigate to the project directory, and run the Django server. Make sure to activate your virtual environment first.

.venv/Scripts/Activate.ps1
py manage.py runserver

Now when you visit localhost:8000, you should see the same site as before, but with some minor styling. Also the increment button now functions as expected.

Don’t make any changes to the files in the build directory, but play around with sample.css. After making a change and saving the file, reload your browser (you may need to hit Ctrl+F5 in order to force a reload without using cached files). You should see the changes reflected in your browser as well as the corresponding file in the build directory.

We could stop here and be happy now that we have the ability to use TypeScript and PostCSS in our Django application, but it’s going to get pretty annoying manually refreshing your browser every time you make a small style or script change. Fortunately there’s a package called Django Browser Reload that can automate this process for us.

First, install the package

pip install django-browser-reload

Add django_browser_reload (note the underscores instead of hyphens) to your INSTALLED_APPS:

  # config/settings.py

  INSTALLED_APPS = [
      "django.contrib.admin",
      "django.contrib.auth",
      "django.contrib.contenttypes",
      "django.contrib.sessions",
      "django.contrib.messages",
      "django.contrib.staticfiles", # <- Must have this
      "hello_webpack",
+     "django_browser_reload",
  ]

Make the following changes to config/urls.py:

  from django.contrib import admin
- from django.urls import path
+ from django.urls import include, path

  from hello_webpack.views import hello

  urlpatterns = [
      path("admin/", admin.site.urls),
      path("", hello),
+     path("__reload__/", include("django_browser_reload.urls")),
  ]

Finally add this middleware to config/settings.py:

  MIDDLEWARE = [
      "django.middleware.security.SecurityMiddleware",
      "django.contrib.sessions.middleware.SessionMiddleware",
      "django.middleware.common.CommonMiddleware",
      "django.middleware.csrf.CsrfViewMiddleware",
      "django.contrib.auth.middleware.AuthenticationMiddleware",
      "django.contrib.messages.middleware.MessageMiddleware",
      "django.middleware.clickjacking.XFrameOptionsMiddleware",
+     "django_browser_reload.middleware.BrowserReloadMiddleware",
  ]

Check out the repository to see more about how this works. Also check out the creator, Adam Johnson’s, website. That guy is a Django wizard.

Anyway, open up the two PowerShell windows/terminals from before and run

npm run watch

in one and

py manage.py runserver

in the other.

Make some change to sample.css like changing the color property for h1 tags and hit save. If the browser doesn’t reload immediately, hit Ctrl+F5 to sort of prime the pump and then future changes should cause page reloads.


Conclusion & Things to Keep in Mind

This is quite possibly one of the most frustrating things to read as a person learning Django—and for that I apologize, but this configuration is suitable for development, not production. There are a few considerations (at least that I can think of off the top of my head) for making this production-ready:

Also bear in mind that your projects may grow to the point where you may need a feature like code splitting to improve your pages’ load times. Or you might decide that your needs would be better met by separating your front and back-ends and relying on a front-end framework, in which case your usage of webpack will look very different.

The configuration in this tutorial works for me because it’s basic, but fairly extensible. I mentioned at the beginning the possibility of using Sass or TailwindCSS. From this point, adding either is a matter of installing their respective packages and configuring them to work with the various config files we created. There isn’t really anything Django-specific to worry about, which is what makes this such a great starting point for projects.

I hope you found this useful and I hope it makes developing with Django even more fun for you!


View the MDX for this page or submit an issue if you noticed any errors!