Django + Lit + Vite: Template Setup and Hot-Reload Workflow

This post walks you through how I set up a hypothetical Django app called acmefront to work with the Lit JavaScript framework and Vite. I’ll also show you a simple, framework-agnostic hot-reload workflow that makes development a breeze. Just a heads-up: all the names and paths I’m using here are just for illustration.

Step 1: Wiring Up the App, Template, URL, and View

First things first, let’s get the basic Django pieces in place. The app exposes a simple index view and a URL to go with it:

from django.shortcuts import render

def index(request):
    return render(request, 'acmefront/index.html')
from django.urls import include, path
from . import views

urlpatterns = [
    path('', views.index, name='acmefront-index'),
]
{% load static %}
<!DOCTYPE html>
<html lang="en" {% if debug %}data-env="development"{% endif %}>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Acme Front - Lit Components</title>
    <link rel="stylesheet" href="{% static 'acmefront/dist/main.css' %}">
    <link rel="icon" href="{% static 'acmefront/favicon.ico' %}" type="image/x-icon">
    
    <!-- Optional: add a small dev indicator so you know you're on dev -->
    <style>
      .dev-indicator { position: fixed; top: 8px; right: 8px; background: #111; color: #fff; padding: 4px 8px; font-size: 12px; border-radius: 4px; z-index: 9999; }
    </style>
</head>
<body>
    <div class="dev-indicator">🔥 DEV + Lit</div>
    <acme-container title="Demo Widgets"></acme-container>
    <script type="module" src="{% static 'acmefront/dist/main.js' %}"></script>
</body>
</html>

Result: Django serves a normal template; Lit web components are delivered via Vite’s bundled main.js and main.css in static/acmefront/dist/ and rendered as custom elements in the HTML.

Step 2: Structuring the Lit Components

With the Django side sorted, let’s look at the Lit components. I write them in TypeScript and register them as custom elements (like acme-container, widget-card, etc.). I also like to use a shared BaseComponent that extends LitElement for any common styles or utilities:

import { LitElement, css } from 'lit';

export class BaseComponent extends LitElement {
  static sharedStyles = css`
    :host { /* design tokens omitted for brevity */ }
  `;
}
// src/main.ts
import './dev-refresh';
import './style.css';
import './components/acme-container';
import './components/widget-card';
import './components/widget-list';

Step 3: Configuring Vite for Django and Lit

Now, let’s get Vite configured to play nicely with our Django and Lit setup. The goal is to have Vite emit assets into the Django static tree and to use modern targets that work well with Lit:

// vite.config.js
import { defineConfig } from 'vite'
import { resolve } from 'path'

export default defineConfig({
  build: {
    outDir: 'static/acmefront/dist',
    rollupOptions: {
      input: { main: resolve(__dirname, 'src/main.ts') },
      output: {
        entryFileNames: '[name].js',
        chunkFileNames: '[name].js',
        assetFileNames: '[name].[ext]'
      }
    },
    emptyOutDir: true,
    manifest: true,
    target: 'es2020'
  },
  server: {
    port: 5173,
    host: 'localhost',
    cors: true,
    hmr: { port: 5173 },
    watch: { usePolling: true, include: ['templates/**/*.html'] }
  },
  optimizeDeps: { include: ['lit'] }
})
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "watch": "vite build --watch",
    "preview": "vite preview",
    "type-check": "tsc --noEmit"
  },
  "dependencies": { "lit": "^3.0.0" }
}

Result: Running npm run build produces main.js/main.css ready for {% static %} references from Django templates.

Step 4: Setting Up Hot-Reloading

Instead of wrestling with Vite’s native HMR inside Django, I prefer a simpler, framework-agnostic refresh mechanism. Here’s how it works.

A browser polling script (src/dev-refresh.ts), which runs only in development, polls a static “marker” file for changes:

class DevRefresher {
  private lastRefreshTime = 0;
  private refreshFile = '/static/acmefront/refresh.txt';
  private pollInterval = 1000;
  constructor() { if (this.isDevelopment()) this.startPolling(); }
  private isDevelopment() { return ['localhost','127.0.0.1'].includes(window.location.hostname) || window.location.hostname.includes('dev'); }
  private async checkForChanges() {
    const res = await fetch(this.refreshFile + '?_=' + Date.now(), { cache: 'no-cache' });
    if (res.ok) {
      const t = parseFloat(await res.text());
      if (this.lastRefreshTime && t > this.lastRefreshTime) window.location.reload();
      else this.lastRefreshTime = t;
    }
  }
  private startPolling() { this.checkForChanges(); setInterval(() => this.checkForChanges(), this.pollInterval); }
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => new DevRefresher()); else new DevRefresher();
# management/commands/vite_dev.py
class ViteHandler(FileSystemEventHandler):
    def on_modified(self, event):
        if event.is_directory: return
        if event.src_path.endswith(('.html', '.js', '.ts', '.css', '.vue')):
            self.build_vite()
    def build_vite(self):
        subprocess.run(['npm', 'run', 'build'], cwd=self.app_path, check=True, capture_output=True, text=True)
        self.trigger_browser_refresh()
    def trigger_browser_refresh(self):
        refresh_file = self.app_path / 'static' / 'acmefront' / 'refresh.txt'
        refresh_file.parent.mkdir(parents=True, exist_ok=True)
        refresh_file.write_text(str(time.time()))

class Command(BaseCommand):
    def handle(self, *args, **options):
        # locate app path, ensure deps, do initial build, then watch templates/src/static
        ...

Result: Fast, reliable full-page reload without running a separate Vite dev server reverse proxy.

Step 5: Quieting Down the Dev Server Logs

To keep the dev server logs from getting cluttered with polling requests for refresh.txt, you can add a small logging filter in your Django settings and attach it to the console logging:

class SuppressRefreshPollFilter:
    _pattern = re.compile(r'^/static/acmefront/refresh\.txt')
    def filter(self, record):
        if not hasattr(record, 'args'): return True
        try:
            path = record.args[0].split()[1]
            if self._pattern.match(path):
                return False
        except Exception:
            pass
        return True

LOGGING = {
    'filters': { 'suppress_refresh_poll': { '()': SuppressRefreshPollFilter } },
    'handlers': { 'console': { 'class': 'logging.StreamHandler', 'filters': ['suppress_refresh_poll'] } },
    'loggers': { 'django.server': { 'handlers': ['console'], 'level': 'INFO', 'propagate': False } },
}

Step 6: Running It All Locally

Here’s how you get everything up and running on your local machine.

In Terminal 1, run the Django dev server:

python manage.py runserver

In Terminal 2, start the Vite watcher that’s integrated with Django:

python manage.py vite_dev --app acmefront

A Few Final Notes

And that’s it! A straightforward way to integrate Django, Lit, and Vite with a reliable hot-reload workflow. Happy coding!