6 min read

Using Vite with Django in 2026

I ran webpack on every Django project I had for years but in 2026 I use Vite instead, with no wrapper package involved, and this is the whole setup I use.

Using Vite with Django in 2026
Isaac Bythewood Isaac Bythewood
2026-04-26

I ran webpack on every Django project I had for years and over that time the config kept growing, the builds kept getting slower, and the plugin churn never really stopped. In 2026 I use Vite instead and the integration with Django is thin enough that I don't bother with a wrapper package at all. The vite.config.js is a single file, Django just sees regular static files, and {% static %} works the way it always has.

I use this on analytics and status so most of the snippets below are pulled straight out of those two projects.

Why I moved off webpack

There were a few reasons for that:

If you're on a giant webpack codebase and a full migration sounds like too much, Rspack is a Rust-based drop-in replacement that works with most webpack configs as-is. For a Django project I'd skip the middle step and go straight to Vite.

The Vite config

Each Django app has a static_src/index.js that imports its own SCSS and scripts, and Vite takes those entry points and writes the bundle out to a single static/ directory inside the project's main app. This is the vite.config.js straight out of analytics:

import { resolve } from "path";
import { defineConfig } from "vite";

export default defineConfig({
  base: "/static/",
  build: {
    outDir: resolve(__dirname, "analytics/static"),
    emptyOutDir: true,
    rollupOptions: {
      input: {
        base: resolve(__dirname, "analytics/static_src/index.js"),
        pages: resolve(__dirname, "pages/static_src/index.js"),
        properties: resolve(__dirname, "properties/static_src/index.js"),
        collector: resolve(__dirname, "collector/static_src/index.js"),
      },
      output: {
        entryFileNames: "[name].js",
        assetFileNames: (assetInfo) => {
          if (/\.(png|jpg|gif|svg|webp)$/.test(assetInfo.name)) {
            return "images/[name][extname]";
          }
          return "[name][extname]";
        },
      },
    },
  },
  css: {
    preprocessorOptions: {
      scss: { quietDeps: true },
    },
  },
});

A few notes:

  • base: "/static/" matches Django's STATIC_URL, so anything Vite writes into the bundle (image references, font URLs) resolves against the same path Django serves from.
  • Each Django app gets its own entry under rollupOptions.input. Vite emits a base.js, pages.js, properties.js, and collector.js into the same output folder and templates load whichever they need.
  • entryFileNames: "[name].js" keeps the output names predictable, and WhiteNoise hashes them at collectstatic time anyway so I don't need Vite doing it too.
  • emptyOutDir: true wipes the output between builds, since Vite refuses to clean a directory outside its project root unless you set it, and without it you get a warning every build and stale files pile up.

Django settings

I don't use django-vite or any other integration package because Django doesn't really need to know that Vite exists, it just sees a static directory full of pre-built files and serves them.

# settings.py

STATIC_URL = "static/"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
STATICFILES_DIRS = (BASE_DIR / "analytics/static",)
STATIC_ROOT = BASE_DIR / "static"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]

STATICFILES_DIRS points at Vite's output directory so collectstatic and the dev server both pick it up, and CompressedManifestStaticFilesStorage hashes every file during collectstatic and rewrites the references in CSS to point at the hashed names. Don't append ?v=... query strings to {% static %} though, WhiteNoise expects to control those URLs and the manifest will get out of sync on you.

The templates stay about as plain as they ever were:

<link href="{% static 'base.css' %}" rel="stylesheet">
<script type="module" src="{% static 'base.js' %}"></script>

In dev that resolves to /static/base.css and in production WhiteNoise rewrites it to /static/base.abc123.css for you automatically.

Running Django and Vite together

Vite has a watch mode that rebuilds on every change and I run it right next to Django's runserver out of a Makefile:

.PHONY: run runserver vite

run: install
	${MAKE} -j2 runserver vite

runserver:
	uv run python manage.py runserver 0.0.0.0:8000

vite:
	bun run dev

make -j2 runs both targets in parallel in the same terminal, and the dev script in package.json is just vite build --watch:

{
  "scripts": {
    "dev": "vite build --watch",
    "build": "vite build"
  }
}

I don't use Vite's dev server myself. It's great for SPAs but on a Jinja-rendered page HMR doesn't really do much of anything for you and you've added an extra port and an integration layer to get it. vite build --watch just writes plain files to disk that look identical to what production serves, so I hard refresh in the browser the same way I always did with webpack.

If you really want HMR then django-vite is the package most people reach for and it works well, you can use it if you want! I just don't think it's worth the extra dependency on a multi-page app.

Per-app static_src

For the analytics dashboard the entry point looks like:

// properties/static_src/index.js
import "./scripts/property_graphs.js";
import "./scripts/property_map.js";
import "./scripts/property_date_select.js";
import "./scripts/property_filters.js";

import "./styles/print.scss";

And in the dashboard template:

{% block extra_js %}
<script type="module" src="{% static 'properties.js' %}"></script>
{% endblock %}

New JS for an app goes in that app's static_src/ and shows up as <app-name>.js in the static directory, with no webpack chunks config, no splitChunks, and no Babel preset to keep current.

Production

The Dockerfile runs bun run build once during the image build and then collectstatic, and after that there's no Node or Vite at runtime at all, just Gunicorn serving Django and WhiteNoise serving the hashed files.

RUN bun install --frozen-lockfile
RUN bun run build
RUN uv run python manage.py collectstatic --noinput

Total build time across bun install, vite build, and collectstatic is under 30 seconds on these projects, where webpack used to take three or four minutes to do the same work.

Sources

Honza HrubĂ˝'s "Goodbye Webpack, Hello Rspack" is the case for the drop-in path if a full migration isn't realistic. Ratchapol Thaworn's "Migrating from Webpack to Vite" and HK Lee's "Vite vs. Webpack in 2026" go further on the Vite side. The Vite docs and the django-vite README are the references I keep open when I'm actually wiring it up. Saas Pegasus has a longer Django + Vite walkthrough too if you want one with React and Tailwind on top.

Webpack served me well for a long time, I just don't have much of a reason to keep using it on anything new.


Some posts in similar tags to this one.

Finding broken external links on websites using Scrapy
Finding broken external links on websites using Scrapy
Broken links are a problem for any content driven website as it ages, find them quickly and easily with Scrapy.
Isaac Bythewood Isaac Bythewood
2022-07-23
Using CodeMirror to show formatted code in Wagtail
Using CodeMirror to show formatted code in Wagtail
Going through all the steps to use CodeMirror with Wagtail to show formatted code on the frontend of your site.
Isaac Bythewood Isaac Bythewood
2022-06-11
Caddy configuration for Django with some sensible defaults
Caddy configuration for Django with some sensible defaults
Caddy is a great web server with sensible defaults but there a few things that I need to configure to have perfect synergy with Django.
Isaac Bythewood Isaac Bythewood
2022-06-04