TIL Making a SPA for Django Posted on January 07, 2026 by Dave Fowler
I love reinventing, or atleast re-thinking the wheel. I love the challenge of digging into something and trying to see if I could do anything better or cleverer that what's already out there. Often there's not (spoiler alert - there wasn't here) but I'm left with a much deeper understanding and appreciation for having approached the problem with that curiousity, considering each part and whether its needed or could be done differently.
This time, I went down a rabbit hole trying to make a simple Django app feel like a Single Page Applications (SPA). Smooth navigation. Instant search. No page flashes. The works.
The Starting Point
I'd looked at HTMX and Turbo before. They're the obvious choices. But something about them bugged me.
HTMX has all these hx- attributes you have to remember:
<input
type="search"
name="q"
hx-get="."
hx-trigger="keyup changed delay:300ms"
hx-target="body"
hx-swap="outerHTML"
hx-push-url="true"
/>
Five attributes just to make a search box work! And if you forget one, it breaks in confusing ways. The syntax felt verbose and the defaults weren't helpful for common cases.
Turbo is smoother out of the box, but for anything interactive (like debounced search), you need Stimulus—a whole separate JavaScript framework. And for whatever reason, Turbo never got the Django community love that HTMX did.
So naturally, I decided to build something myself. What could go wrong?
The Problem SPAs Solve
People expect reactive interfaces now. Gone are the days when users were okay with every click triggering a full page reload—the white flash, the scroll position resetting, the brief moment of nothing. Modern users want instant feedback. Click a filter, see results immediately. Type in a search box, watch results update live.
Single Page Applications (SPAs) solved this. React, Vue, Angular—they keep everything in JavaScript, update the DOM surgically, and make interfaces feel alive.
The Problem SPAs Create
But SPAs come with baggage.
Your frontend becomes a parallel application. You're syncing state between client and server. You're writing validation logic twice. You're managing JavaScript complexity that grows and grows. You need build tools, bundlers, and a whole separate mental model for your frontend.
For many applications—especially the kind Django excels at: admin panels, dashboards, internal tools, content-heavy sites—this is overkill. You just want your Django app to feel a bit smoother.
The Server-Driven Alternative
There's another approach: keep state on the server, but make page transitions smooth.
Instead of building a JavaScript application that talks to an API, you render full HTML on the server (like Django already does), but intercept link clicks with a small script that fetches the new page via AJAX and swaps the content without a full browser refresh.
This isn't new. GitHub pioneered PJAX back in 2011. Rails introduced Turbolinks in 2012, which evolved into Turbo. HTMX brought similar ideas to the broader web community.
The appeal is obvious: keep your server-rendered templates, your Django views, your simple mental model—but get that SPA smoothness. Some people call it "HTML over the wire" or "hypermedia-driven applications." I'll call it server-driven SPA.
The Problem in Server-Driven SPAs
Here's what I got stuck on:
If every interaction still renders the full page on the server, aren't you running all your queries every time? User clicks one thing, and you re-query the entire page's worth of data?
That seems wasteful. Slow. Expensive at scale.
How do you avoid re-running queries that don't need to run—without making your code a mess?
If you're navigating from ?search=foo&focus_id=123 to ?search=foo&focus_id=456, only focus_id changed. The search sidebar should stay the same. Why re-query the todo list?
Django makes this tricky because views and templates are separated. If I want to conditionally run a query, I have to check the condition in the view. But I also have to conditionally render in the template. The logic gets repeated:
# views.py
def todos(request):
context = {}
if should_render_sidebar: # Check once
context['todo_list'] = Todo.objects.filter(...)
if should_render_focus: # Check again
context['focused_todo'] = get_object_or_404(...)
return render(request, 'todos.html', context)
<!-- todos.html -->
{% if todo_list %}
<!-- Check AGAIN -->
<aside>{% for todo in todo_list %}...{% endfor %}</aside>
{% endif %} {% if focused_todo %}
<!-- And AGAIN -->
<main>{{ focused_todo.title }}</main>
{% endif %}
How do I make this DRY? How do I put the conditional logic in one place?
Attempt 1: Reactive URL State
My first idea: all state lives in the URL, and we reactively track what changed.
I'd call it django-riff—musical (like Django itself), and it stood for "Reactive-Diff." I love a good name.
The concept: middleware compares the previous URL to the current one and tells you what parameters changed:
class RiffChangesMiddleware:
def __call__(self, request):
prev_url = request.headers.get('X-Riff-Previous-URL')
if prev_url:
prev_params = parse_qs(urlparse(prev_url).query)
curr_params = dict(request.GET)
request.changes = Changes(prev_params, curr_params)
else:
request.changes = Changes.all() # First load
return self.get_response(request)
Now views could be reactive—only run queries for things that changed:
def todos(request):
search = request.GET.get('search', '')
focus_id = request.GET.get('focus_id')
components = {}
if request.changes.search:
todo_list = Todo.objects.filter(user=request.user).search(search)
components['search_sidebar'] = render_component('sidebar', {'todo_list': todo_list})
if request.changes.focus_id:
todo = get_object_or_404(Todo, id=focus_id) if focus_id else None
components['focused_todo'] = render_component('focused', {'todo': todo})
return render_riff('main.html', components)
Each component declared its dependencies implicitly through which request.changes.* it checked. Only render what changed. Pretty elegant!
But... I still had that duplication problem. The view decides whether to render, but the template still needs to know how to render each component. And I needed the frontend to send the previous URL with every request.
Attempt 2: Lazy Function Wrappers
Then I had a realization: what if the template pulled data instead of the view pushing it?
If I wrap each query in a function, and the template calls that function only when it needs the data, then:
- If the template section isn't rendered → function never called → query never runs
- No double-checking conditions in view AND template
def todos(request):
search = request.GET.get('search', '')
focus_id = request.GET.get('focus_id')
def get_sidebar():
return {
'todo_list': Todo.objects.filter(user=request.user).search(search)[:100]
}
def get_focused():
return {
'todo': get_object_or_404(Todo, id=focus_id) if focus_id else None
}
return render_riff(request, 'main.html', {
'search_sidebar': get_sidebar, # Pass the function, don't call it
'focused_todo': get_focused,
})
The template (or framework) only calls get_sidebar() if it needs to render the sidebar. If the sidebar component isn't being updated, the function never runs, the query never executes.
This pattern actually fits naturally with Django's class-based views. CBVs already have get_context_data()—the idea of methods that provide context is built in. We just need to split it into separate methods per component:
class TodosView(RiffView):
template_name = 'todos/main.html'
def get_sidebar_context(self):
"""Only called if sidebar needs rendering."""
search = self.request.GET.get('search', '')
return {
'todo_list': Todo.objects.filter(user=self.request.user).search(search)
}
def get_focused_context(self):
"""Only called if focused panel needs rendering."""
focus_id = self.request.GET.get('focus_id')
if not focus_id:
return None
return {
'todo': get_object_or_404(Todo, id=focus_id)
}
Or if you want reusable components across views, you could extract them into their own classes:
class SearchSidebar(RiffComponent):
depends_on = ['search']
def get_context(self):
search = self.request.GET.get('search', '')
return {
'todo_list': Todo.objects.filter(user=self.request.user).search(search)
}
class FocusedTodo(RiffComponent):
depends_on = ['focus_id']
def get_context(self):
focus_id = self.request.GET.get('focus_id')
if not focus_id:
return None
return {'todo': get_object_or_404(Todo, id=focus_id)}
class TodosView(RiffView):
template_name = 'todos/main.html'
components = [SearchSidebar, FocusedTodo]
Either way, the pattern is the same: context-fetching logic lives in methods that only get called when needed. It's Django's CBV philosophy extended to partial rendering.
Self-documenting! Each component declares its dependencies, provides its own data, and the framework handles the rest.
There was still some complexity with URLs. If I wanted a link in the sidebar to change just focus_id, I'd have to write:
<a href="?search={{ search }}&sort={{ sort }}&focus_id={{ todo.id }}"></a>
But wait—now my sidebar template references search and sort. That means the sidebar component effectively depends on those parameters too. If sort changes, the sidebar has to re-render just because its links include sort in the URL.
This defeats the whole purpose of breaking things into components with separate dependencies!
I needed a URL helper that preserved existing parameters while changing just one:
<!-- Changes focus_id, preserves everything else automatically -->
<a href="{% riff_url focus_id=todo.id %}"></a>
So now I'm building URL helpers, component classes, a template tag library... I'm building a framework.
The Caching Realization
Then I stopped and asked myself a different question:
If I'm already using caching, do I even care about skipping queries?
Most production Django apps use something like django-cacheops:
CACHEOPS = {
'*.*': {'ops': 'all', 'timeout': 60*15},
}
This automatically caches all queries. Run the same query twice? Second time is instant—it's just a Redis lookup. The cache auto-invalidates when you write to those models.
If queries are cached, then "re-running" a query that didn't need to run is basically free. The whole premise of my optimization—avoid unnecessary queries—becomes irrelevant.
I don't need request.changes. I don't need component dependencies. I don't need lazy function wrappers.
I can just... render the whole page. Every time.
def todos(request):
search = request.GET.get('search', '')
focus_id = request.GET.get('focus_id')
todos = Todo.objects.filter(user=request.user)
if search:
todos = todos.filter(title__icontains=search)
focused = None
if focus_id:
focused = get_object_or_404(Todo, id=focus_id)
return render(request, 'todos.html', {
'todos': todos,
'focused': focused,
})
Just normal Django. No framework. No components. The queries are cached anyway.
But Wait—Is Full-Page Rendering Expensive?
It doesn't feel right to render the whole page when only part is going to be updated... so inefficient. Surely there's a performance hit and a cost to rendering the entire page every time instead of just the parts that changed?
Let's actually look at the numbers.
The Performance Hit
How much slower is a full page render vs. a partial?
Server-side rendering time:
- Full page template: ~10-20ms
- Single component template: ~2-5ms
- Delta: ~10-15ms
With cached queries, the database isn't the bottleneck—template rendering is. And we're talking milliseconds.
Network transfer:
- Full page HTML: ~15-25KB (gzipped)
- Partial component: ~2-5KB (gzipped)
- Delta: ~15KB
At typical broadband speeds, 15KB takes about 5ms to transfer. On mobile, maybe 20-30ms.
Total perceived difference: ~20-50ms
Can users perceive 30ms? Studies say humans notice delays around 100ms. We're well under that.
The "inefficiency" of full page rendering is essentially imperceptible.
The Dollar Cost
But okay, let's talk server costs. This is the same math you should do when deciding whether to build a heavy React frontend vs. server-rendered HTML, by the way.
Scenario: 1 million daily active users, 10 interactions per day each = 10M requests/day.
Extra Bandwidth
A typical page might be 20KB. A partial update (just the changed component) might be 5KB.
- Full pages: 10M × 20KB = 200 GB/day
- Partial updates: 10M × 5KB = 50 GB/day
- Extra bandwidth: 150 GB/day
At AWS CloudFront pricing ($0.085/GB): **$13/day** or ~$400/month
Extra Server Compute
Rendering extra template sections? Maybe 5ms additional per request.
- 10M requests × 5ms = 50M ms = ~14 compute-hours/day
At $0.05/hour (cheap instance): ~$0.70/day or ~$21/month
Total Extra Cost
About $420/month for 1 million daily, very active users.
Four hundred bucks. That's the "waste" of rendering full pages instead of optimized partials. If (a big if) your app gets to this scale you may decide adding the complexity of doing just partial page loads. But it's probably save you a trivial amount.
The Frontend Question
Okay, so the backend is simple: render full pages, cache queries.
But what about the frontend? That's where SPAs feel smooth, right?
HTMX and Turbo both intercept links and do partial page updates—fetching the new page via AJAX and swapping the body without a full browser refresh. No white flash, smoother transitions.
But here's what I realized: partial DOM updates buy you very little and risk missing needed changes.
If the URL changes and I only update the sidebar, what if something else should have changed too? With full page swaps, I never have to think about it. Everything updates. It's predictable.
And with cached queries and modern browsers, full page swaps are fast. The "smoothness" difference is barely perceptible.
So I could use HTMX just for its link interception (that's what hx-boost="true" does), ignore all the partial update stuff, and call it a day.
Coming Full Circle: The Search Input Problem
But remember my original complaint about HTMX? The attribute soup for a simple debounced search:
<input
type="search"
name="q"
hx-get="."
hx-trigger="keyup changed delay:300ms"
hx-target="body"
hx-swap="outerHTML"
hx-push-url="true"
/>
With hx-boost="true" on the body, forms already submit smoothly. But for "search as you type" with debouncing, you need all those attributes. Every time. On every search input.
This is the one thing I actually built: a tiny script that makes forms auto-submit with sensible defaults.
// autosync.js - 15 lines
const DEBOUNCE = { search: 300, text: 0, 'select-one': 0, checkbox: 0, radio: 0 };
document.querySelectorAll('form[data-autosync]').forEach((form) => {
form.querySelectorAll('input, select').forEach((el) => {
const delay = el.dataset.debounce ?? DEBOUNCE[el.type] ?? 0;
let timeout;
el.addEventListener(delay > 0 ? 'input' : 'change', () => {
clearTimeout(timeout);
timeout = setTimeout(() => form.requestSubmit(), delay);
});
});
});
Now a search form is just:
<form data-autosync>
<input type="search" name="q" />
<select name="sort">
...
</select>
</form>
type="search"→ automatically debounces 300ms, submits on inputselect→ automatically submits on change- Override with
data-debounce="500"if needed
No HTMX attribute soup. No Stimulus controllers. Just one attribute on the form.
The Final Stack
After all that exploration, here's what I actually use:
1. Cache Everything
pip install django-cacheops redis
CACHEOPS_REDIS = 'redis://localhost:6379/1'
CACHEOPS = {'*.*': {'ops': 'all', 'timeout': 60*15}}
2. Smooth Navigation
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<body hx-boost="true"></body>
3. Auto-Submit Forms
<script>
const DEBOUNCE = { search: 300, text: 0, 'select-one': 0, checkbox: 0, radio: 0 };
document.querySelectorAll('form[data-autosync]').forEach((form) => {
form.querySelectorAll('input, select').forEach((el) => {
const delay = el.dataset.debounce ?? DEBOUNCE[el.type] ?? 0;
let timeout;
el.addEventListener(delay > 0 ? 'input' : 'change', () => {
clearTimeout(timeout);
timeout = setTimeout(() => form.requestSubmit(), delay);
});
});
});
</script>
4. Normal Django Views
def todos(request):
search = request.GET.get('search', '')
todos = Todo.objects.filter(user=request.user)
if search:
todos = todos.filter(title__icontains=search)
return render(request, 'todos.html', {'todos': todos})
That's it. No framework. No components. No reactive state tracking. No partial updates. Just Django with caching and 15 lines of JavaScript.
It's so dumb and simple I love it. I hope to never write another React app again.
The autosync script is too small to package as a library, but feel free to copy it. Sometimes 15 lines is all you need.