Django Doesn't Have to Feel Old: Modernize Your Frontend with Vite

Django Doesn't Have to Feel Old: Modernize Your Frontend with Vite

Still Using Plain CSS and JS in Django? Modernize with Vite

I've heard this complaint from Django developers for years, and I've made it myself more than once, usually at 11pm while staring at a CSS file that somehow became a crime scene. The backend is fine. It's always fine. It's the frontend that slowly starts falling apart. Script tags multiply in templates, CSS files become museums of forgotten decisions, and suddenly the frontend world is telling you that the answer is another framework, another build tool, and another layer of abstraction between you and a button on a page.

That's usually the moment someone says, "Maybe we need React," or worse, "Let's turn this into a full SPA." Because apparently the best way to fix a messy stylesheet is to introduce a separate frontend project, another deployment pipeline, and the need to ping another team member just to change a button style. Congratulations, your CSS problem just became a coordination problem.

The funny thing is, Django templates were never the issue. For dashboards, internal tools, and most business applications, server-rendered Django is still one of the most productive ways to build software. What was missing wasn't an SPA. It was modern frontend tooling.

That's where Vite comes in.

Add Vite to Django and you keep the workflow you already know, but your frontend finally escapes the JavaScript chaos: hot reload, real modules instead of a global namespace where every script is fighting for survival, TypeScript catching the mistakes JavaScript politely ignores until production, and a proper build pipeline without dragging your app into full SPA ceremony.

Most developers don't actually want the SPA. They want the workflow that comes with it. Solve that problem, and Django doesn't need replacing. It just finally gets the frontend tooling it was missing.

Why Your Current Setup Is Quietly Hurting You

1. Stop Treating CSS Like a File You Just Keep Feeding

Nobody starts a Django project thinking:

"Today I will create a 6,000-line CSS monster that nobody understands and everyone is afraid to touch."

It starts harmlessly. A few styles here. A few overrides there. One quick fix because "we'll clean it up later." Nobody cleans it up later.

Six months later, styles.css has become a digital landfill full of duplicated colors, abandoned classes, accidental global selectors, and comments like:

/* do not remove, breaks navbar somehow */

Nobody knows why it breaks. Nobody wants to find out. The comment survives every refactor like an ancient curse passed down from developer to developer.

Eventually, adding a new page becomes a naming competition with yourself:

.card
.dashboard-card
.dashboard-card-wrapper
.dashboard-card-wrapper-inner
.dashboard-card-wrapper-inner-title {
  ...
  }

At some point you are no longer writing CSS. You are naming a very complicated family tree.

The problem is not CSS. CSS is fine. The problem is treating your stylesheet like a giant notebook where every developer writes their thoughts directly on top of everyone else's thoughts.

Vite gives your CSS an actual pipeline instead of a folder. It resolves imports, runs PostCSS and Sass if you use them, and spits out a production build with source maps attached.

Instead of one enormous stylesheet containing the entire history of every Django template since the beginning of time, you can organize styles around features:

styles/
├── base.css
├── forms.css
├── dashboard.css
└── blog.css

Your styles become something you can actually understand instead of a mystery you unravel with developer tools.

Vite also doesn't force you into one CSS philosophy. Pick whichever of these you actually like:

  • Plain CSS split into logical files
  • Sass with variables, mixins, and reusable design tokens
  • Tailwind if utility classes are your preferred way of avoiding a giant stylesheet
  • Bootstrap through Sass if you want customization instead of downloading the entire framework because one button looked nice

The important change is not choosing Tailwind, Sass, Bootstrap, or plain CSS. The important change is moving from "the browser will somehow figure out all these random files" to "the application has a build pipeline that knows exactly what it is doing."

Without a build process, CSS frameworks are usually consumed as prebuilt files. You receive everything whether you use it or not. Need three Bootstrap components? Congratulations, your users now receive an entire framework, because apparently those three buttons deserve a full warehouse delivery.

With Vite, your frontend assets become inputs to a controlled build process instead of a growing pile of files nobody wants to delete.


2. Get a Development Workflow That Fights Back

The traditional Django frontend workflow goes something like this:

  1. Change CSS
  2. Save file
  3. Switch to browser
  4. Press F5
  5. Wait
  6. Find your scroll position again
  7. Reopen the modal
  8. Realize the button is still two pixels wrong

Repeat until your keyboard has developed more muscle memory than you have.

The worst part is when you are testing a form. You spend five minutes filling fields, refresh the page, lose everything, and then calmly repeat the entire process like this is a normal thing humans should accept in 2026.

Vite replaces this workflow with Hot Module Replacement (HMR). Save a CSS file and the new styles get injected into the page immediately, no reload, no lost scroll position, no wiped-out form you were halfway through filling in. Your application state just sits there, unbothered, while you decide a border radius should be 10px instead of 8px.

Change JavaScript and Vite reloads the page for you, instantly, without you touching F5. (Full module-level hot swapping requires code that opts into HMR, which frameworks like React and Vue wire up for you; plain JS modules in Django templates get an automatic reload instead. Still a very different life from the manual ritual above.)

Instead of edit → refresh → navigate → test → repeat,

you get edit → see result → continue.

The difference sounds small until you experience it. Going back to manual refreshes afterward feels like developing through a mail system.

The same applies when something breaks. A missing import, invalid JavaScript, or a broken dependency all get flagged immediately instead of surfacing after you've clicked through six pages and accidentally reached the one piece of code nobody has touched since 2019.

Add TypeScript and your editor becomes an early warning system: wrong function arguments, misspelled properties, possible undefined values, all caught before you even run the code. Your editor stops politely watching you make mistakes and starts telling you to fix things before you continue.

Vite also generates source maps, automatically in development, and with a single config line for production builds, so debugging does not mean opening minified files and trying to understand:

t.prototype.xyz=function(e){return e&&e.a}

which looks less like JavaScript and more like a message recovered from an alien satellite.

Your debugging process stays connected to the code you actually wrote.


3. Your <script> Tags Are Becoming a Crime Scene

If your Django templates are full of:

<script src="library.js"></script>
<script src="another-library.js"></script>
<script src="final-version.js"></script>
<script src="final-version-fixed.js"></script>

you are not managing dependencies. You are preserving archaeological artifacts.

This approach worked when frontend development meant downloading a file, adding a script tag, and hoping nothing exploded. But modern applications have dependencies, and lots of them.

Without a build system, every script gets thrown into the same global scope like a box of random cables nobody wants to organize. One developer writes:

const data = [];

Another developer writes the exact same line in a completely different file. Now adding a chart breaks form validation because two unrelated parts of your application decided they both owned the same variable. Excellent teamwork.

Then comes script ordering. This file must load before that file, except that file modifies something created by another file, except a third file expects the second file to already exist. Eventually you have a carefully balanced tower of <script> tags where removing one line causes the entire application to collapse.

Vite replaces this with JavaScript modules. Instead of hoping functions magically appear on window, dependencies are explicit:

import { getData } from "./api";

Each file declares what it needs, and the tooling understands the dependency graph. The browser receives organized modules instead of a box labeled "important cables, probably, do not unplug."

Third-party libraries become simple too. Need Chart.js? Install it:

npm install chart.js

Import what you need:

import { Chart, LineController, LineElement, PointElement } from "chart.js";

Chart.register(LineController, LineElement, PointElement);

No searching through documentation for a mysterious browser bundle. No downloading random files into the assets folder. No wondering whether production is running version 3.5.14.2.0, or whatever somebody silently changed in a template six months ago.

Dependencies live in package.json. Versions are controlled through lockfiles. Updates happen intentionally. Your frontend stops depending on mysterious files living in random folders like forgotten family secrets.

You also get basic security tooling. Run:

npm audit

and npm will check your dependencies against known vulnerabilities. Instead of wondering whether that random JavaScript file from three years ago has a security issue, you get an actual report and a way to fix it.

Vite also handles the production build: it bundles and minifies your JavaScript, drops unused code when a package supports tree shaking, and names files so browsers can cache them safely. Your users download what your app actually needs instead of the entire frontend pantry because one recipe called for a teaspoon of sugar.

Put it together and your CSS stops being a mess, your JavaScript stops fighting itself over global variables, and your dependencies stop being mystery files nobody remembers adding. Django keeps doing what it's always done well. The frontend just stops being the part you apologize for.

The old workflow hurts, quietly, a little more each month, and none of that requires abandoning Django to fix. So here's the practical part: adding a frontend pipeline to a Django project without throwing away your templates, views, or anything else that already works. No rewrite, no SPA, no second repository. One Django package, one config file, and about an afternoon of your time.

Setting Up Django + Vite From Scratch

Django ships with its own static file handling, but it has no idea how to run a modern JS build pipeline: bundling, TypeScript, hot reload, hashed filenames for cache busting. django-vite bridges that gap. Vite builds your assets, and django-vite gives you template tags that read Vite's manifest and output the right <script>/<link> tags, whether you're in dev (pointing at the Vite dev server) or in production (pointing at hashed build output).

This walks through wiring it up on a standard Django project, step by step.

1. Install the Python side

You need two things: The django-vite package and django itself (assumed already installed).

pip install django-vite

Add it to requirements.txt so it's pinned:

django-vite==3.1.0

Then register it as an app in settings.py:

INSTALLED_APPS = [
    ...
    "django.contrib.staticfiles",
    "django_vite",
]

django_vite doesn't add any models or migrations. It gives you the {% load django_vite %} template tags, and it's also what resolves which collected static file to actually point those tags at, reading the Vite manifest to map a source path like main.ts to its hashed, collected output filename (main.a1b2c3d4.js) instead of you hardcoding it.

2. Set up the npm side

Vite itself is a Node tool, so it lives outside Python entirely. From your project root:

npm init -y
npm install --save-dev vite typescript tailwindcss @tailwindcss/vite

If you're building with TypeScript (recommended, since django-vite's examples assume it), add a minimal tsconfig.json next to package.jsontailwindcss and its @tailwindcss/vite plugin are what let you write utility classes in your CSS and have Vite compile them, no separate PostCSS config needed with Tailwind v4. Any other UI libraries you plan to use (date pickers, select widgets, etc.) also go in devDependencies since they get bundled at build time, not served raw.

Add build scripts to package.json:

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

dev starts the Vite dev server (hot reload while you work), build produces the production bundle Django will actually serve.

3. Folder structure

Keep three things apart: the source assets you write, the build output Vite generates, and the static files Django serves as-is (images, favicons, no build step needed). A minimal layout looks like this:

project-root/
├── assets/
│   ├── django/          # static files served as-is (images, favicons)
│   │   └── img/
│   └── vite/            # source files that get bundled
│       ├── scripts/
│       │   └── main.ts
│       └── styles/
│           └── style.css
├── dist/
│   └── vite/            # Vite build output (gitignored)
├── package.json
├── tsconfig.json
└── vite.config.ts

assets/vite is your source of truth: every entry point you want bundled (a main script, a stylesheet, maybe a page-specific script) lives here. dist/vite is generated by npm run build and should never be edited by hand or committed. Add it to .gitignore.

Why two folders under assets/? They go through completely different pipelines, and mixing them causes real problems:

  • assets/vite/ holds anything that needs to be processed: TypeScript that must be compiled to JS, CSS that gets minified and has imports resolved, code that changes often during development. Vite watches this folder, bundles it, and stamps a content hash on the output filename for cache busting.
  • assets/django/ holds anything that should be served unmodified: logos, favicons, email images. These don't need compiling, and Vite doesn't need to know they exist at all. Django's staticfiles app serves them directly.

If you put an image inside assets/vite/, Vite will try to process it as part of a bundle graph it doesn't need to touch, and it becomes invisible to Django until the next build. If you put a .ts file inside assets/django/, Django will happily serve the raw, uncompiled TypeScript to the browser, which the browser can't run. Keeping the split makes it obvious, just by which folder a file lives in, which tool is responsible for it.

4. Create your entry files

These are the two files you just referenced in the tree above. Keep them tiny to start, everything else gets built up over time.

assets/vite/scripts/main.ts:

console.log('main.ts loaded');

In practice this file is just the entry point that imports whatever else your page needs (other .ts modules, third-party JS libraries). Vite follows those imports and bundles them automatically, so you never list them in vite.config.ts, only main.ts itself.

assets/vite/styles/style.css:

@import "tailwindcss";

/* Tell Tailwind where to scan for class names, since your templates
   live outside this assets/ folder and Tailwind can't find them otherwise */
@source "../../../**/*.html";

That's the minimum needed for Tailwind v4 to work: the @import "tailwindcss"; pulls in Tailwind's base styles and utility classes, and @source tells it which files to scan for class names in use (Django templates, in this case) so it knows what to actually generate. Everything else, custom themes, fonts, component overrides, gets added underneath as the project grows.

5. Configure Vite

vite.config.ts needs to know three things: where your entry points are, where to put the build output, and that it must emit a manifest (that manifest is what django-vite reads to resolve filenames). Since we're using Tailwind, it also needs the @tailwindcss/vite plugin so Vite knows how to process the @import "tailwindcss" in style.css.

import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  base: "/static/",
  build: {
    outDir: "./dist/vite/",
    manifest: true,
    rollupOptions: {
      input: {
        "main-ts": "./assets/vite/scripts/main.ts",
        "main-css": "./assets/vite/styles/style.css",
      },
    },
  },
  server: {
    port: 9091,
    strictPort: true,
    cors: true,
  },
  plugins: [
    tailwindcss(),
  ],
});

A few things matter here:

  • base: "/static/": must match Django's STATIC_URL, so asset URLs generated by Vite line up with what Django's static file serving expects.
  • manifest: true: without this, django-vite has no way to map main.ts to its hashed output filename (main.a1b2c3d4.js). This is the one setting that makes the whole integration work.
  • rollupOptions.input: every file listed here becomes a separate entry point you can reference from templates with {% vite_asset %}. Only list files you actually load directly in a template; imports inside those files get bundled automatically, you don't list those separately.
  • plugins: [tailwindcss()]: without this plugin, Vite treats @import "tailwindcss" as a normal CSS import and doesn't know to run Tailwind's class generation over it.
  • server.port: pick a fixed port and note it down. Django needs the same port to find the dev server.

6. Wire up Django settings

This is where dev and production diverge, since dev mode talks to the live Vite server while production reads the manifest from disk.

STATIC_URL = "static/"

if DEBUG:
    STATICFILES_DIRS = [
        BASE_DIR / "assets/django",
    ]
    STATIC_ROOT = BASE_DIR / "static"
    DJANGO_VITE_ASSETS_PATH = STATIC_ROOT / "dist/vite"
    DJANGO_VITE_DEV_SERVER_PORT = "9091"
else:
    STATICFILES_DIRS = [
        BASE_DIR / "assets/django",
        BASE_DIR / "dist/vite",
    ]
    STATIC_ROOT = BASE_DIR / "static"
    DJANGO_VITE_ASSETS_PATH = BASE_DIR / "dist/vite"
    DJANGO_VITE_MANIFEST_PATH = BASE_DIR / "dist/vite/.vite/manifest.json"

DJANGO_VITE_DEV_MODE = DEBUG

Notice STATICFILES_DIRS only lists assets/django in dev, but lists both assets/django and dist/vite in production. This tracks straight back to the two-folder split above:

  • In devDJANGO_VITE_DEV_MODE = True means django-vite doesn't read built files at all. It injects a <script type="module" src="http://localhost:9091/..."> straight from the running Vite dev server. So dist/vite isn't needed in STATICFILES_DIRS yet; only the untouched images in assets/django need to be discoverable by Django's staticfiles system. DJANGO_VITE_ASSETS_PATH is still set here so that collectstatic has somewhere to look if it's run against a dev build, but it's not on the hot path.
  • In production, there is no dev server running, so django-vite reads manifest.json (produced by npm run build) to resolve each source file to its real hashed output filename inside dist/vite. That's why dist/vite is added to STATICFILES_DIRS here: it's now a real folder of files Django needs to serve, sitting alongside the untouched images from assets/django.
  • DJANGO_VITE_MANIFEST_PATH must point at the exact file Vite wrote. It lives at <outDir>/.vite/manifest.json in modern Vite versions.

In other words: assets/django is always in STATICFILES_DIRS, in both branches, because those files exist as-is from day one. dist/vite only shows up once there's something built to serve.

7. Use it in a template

Load the tags and reference the same paths you listed as entry points in vite.config.ts:

{% load django_vite %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
  <link rel="icon" href="{% static 'img/favicon.png' %}" />
  {% vite_asset 'assets/vite/styles/style.css' %}
</head>
<body>
  {% vite_asset 'assets/vite/scripts/main.ts' %}
</body>
</html>

{% vite_asset %} resolves to the dev server URL in DEBUG mode and to the hashed production file otherwise. The template never needs to know which mode it's running in.

The favicon is a good example of a plain static file, not a Vite asset. Drop favicon.png into assets/django/img/favicon.png, no build step, no entry in vite.config.ts, nothing for django-vite to do with it. Reference it with Django's ordinary {% static %} tag, not {% vite_asset %}. Because assets/django is already in STATICFILES_DIRS (step 6), {% static 'img/favicon.png' %} finds it straight away in dev, and collectstatic copies it into STATIC_ROOT alongside your built Vite files for production, same tag, same URL scheme, in both environments.

8. Day-to-day workflow

  • While developing: run npm run dev in one terminal and python manage.py runserver in another. Django templates will pull scripts and styles live from Vite with hot reload.
  • Before deploying: run npm run build, then python manage.py collectstatic so Django's staticfiles system picks up the newly built, hashed files from dist/vite.

That's the whole loop: Vite owns building and hashing your JS/CSS, django-vite owns telling your templates which file to point to, and Django's staticfiles system owns actually serving it.

Common issues

  • Port mismatch between Vite and Django. vite.config.ts's server.port and settings.py's DJANGO_VITE_DEV_SERVER_PORT are two separate numbers that must match by hand, nothing enforces it. If they drift apart, Django tries to load your scripts from the wrong port and the browser console shows connection-refused errors, even though npm run dev is running fine.
  • Forgetting to add a file to rollupOptions.input, which only breaks in production. This is the sneaky one. In dev mode, {% vite_asset %} just talks to the live dev server, which will happily serve any file under assets/vite/ whether or not it's listed as an entry point, so an unlisted page script still works locally and you won't notice anything wrong. In production, django-vite instead looks the file up in manifest.json, and if it was never listed as an entry point, it was never built or added to the manifest, so {% vite_asset %} then fails outright. Always add every file you load directly from a template into rollupOptions.input, and test a real npm run build + DEBUG=False run before trusting a new page.
  • Forgetting manifest: true. Without it, npm run build still produces JS/CSS output, but no manifest.json, so django-vite has nothing to resolve hashed filenames from in production, and every {% vite_asset %} call breaks at once. Easy to miss because dev mode never touches the manifest, so this only surfaces once you deploy.
  • DJANGO_VITE_DEV_MODE not actually tied to DEBUG. If this is hardcoded to True (or left over from copy-pasting settings), production templates try to fetch assets from http://localhost:9091, which doesn't exist on a deployed server. Tie it directly to DEBUG as shown in step 6, not a separate flag.
  • Stale DJANGO_VITE_MANIFEST_PATH. Modern Vite writes the manifest to <outDir>/.vite/manifest.json, not <outDir>/manifest.json like older versions did. Copy-pasting a settings snippet from an older Vite setup silently points django-vite at a file that no longer exists there.
  • base in vite.config.ts not matching STATIC_URL. If base is /static/ but STATIC_URL is something else (or vice versa), asset URLs in the built manifest won't line up with what Django actually serves things under, and you'll get 404s for files that were built successfully.
  • Referencing the rollup input key instead of the file path. {% vite_asset %} takes the source file path (e.g. 'assets/vite/scripts/main.ts'), not the arbitrary key you gave it in rollupOptions.input (e.g. 'main-ts'). Mixing these up is a common first-try mistake and fails immediately, in both dev and prod.

Look at what actually changed here. You didn't rebuild anything in React. You didn't touch a single view, model, or template. You didn't split your project into two codebases that will inevitably drift apart and start resenting each other. You gave Django the tooling modern frontend development expects, a real build pipeline, real dependency management, hot reload, and everything else stayed exactly where it was.

Wrapping up: is it worth the switch?

Yes. HMR alone saves you the daily tax of refresh-and-lose-your-place, and the errors show up in your editor instead of three pages deep in production. The production bundle is smaller because Vite only ships what you use, npm audit actually tells you something useful about your dependencies, and the next developer you hire won't wince when they open the repo.

Your Django backend can stay exactly as it is. It doesn't need saving. Your frontend is the part that's been stuck in 2012, and that's the part Vite fixes.

Django was never the problem. Server-rendered Django is still one of the most productive ways to build dashboards, internal tools, admin systems, and most products where a small team needs to ship features instead of maintaining two codebases that argue with each other over an API contract. What was missing was never a JavaScript framework, it was a build pipeline. Vite fills that gap and nothing more.

Modernizing doesn't mean replacing Django. A solo developer with Django templates and Vite will out-ship a team wrestling with an API layer, a SPA, and the coordination overhead between them.

Django didn't get old. The way we were shipping frontend assets did, and that part takes about an afternoon to fix.

Ready to Level Up Your Stack?

Kalvad helps engineering teams modernize their web architecture and adopt high-performance tooling without slowing down product delivery. We design clean applications pipelines, optimize delivery, and integrate modern developer workflows that scale with your growth. If you want to accelerate build times, give your team a better developer experience, or upgrade your production architecture to handle serious scale, we can help you in. Talk to our engineering team.