Welcome to the Supercharging Django series. In this 8-part masterclass, we will explore why Django + Unpoly is the ultimate stack for developers who want the speed of a Single Page App (SPA) with the simplicity of a traditional framework.
Part 1: Why Unpoly is Django's Best Friend
The Perfect Pair: Django & Unpoly
If you love Django, you probably love its "batteries included" philosophy: the ORM, the Forms, the Admin, the Templates. Everything just works.
But in the modern web, users demand instant navigation and interactive UIs. The industry's answer has been to ditch Django templates and build complex React/Vue SPAs separated by a REST API.
This introduces massive complexity:
- Duplicate validation logic (Frontend + Backend).
- State synchronization nightmares.
- Complex build pipelines (Webpack, npm, node_modules).
The Solution: HTML-Over-The-Wire
Unpoly brings a different philosophy. It allows you to keep your robust Django backend exactly as it is, while giving your frontend the superpowers of a SPA.
Key Benefits of this Stack
- Zero "Glue" Code: No serializers, no JSON APIs. You send HTML, Unpoly swaps it in.
- Native Speed: Pages load instantly because you are swapping fragments, not reloading the full browser window.
- SEO Ready: Search engines see standard HTML. No complex Server-Side Rendering (SSR) hacks needed.
- Instant Modals: Open any Django URL in a modal with a single HTML attribute.
- Developer Joy: You stay in Python. You write standard Django Views. You get a modern app.
The Project: "Page Less"
To demonstrate these benefits, we will build Page_Less, a production-grade blogging platform.
We will cover:
- How to set up the "One Layout to Rule Them All".
- Building a modal-based Authentication flow (Login/Signup).
- Creating a "live search" that feels instant.
- Handling CRUD operations without full page reloads.
Supercharging Django: Part 2 - Setting the Foundation (Django + Unpoly)
The beauty of Unpoly is that it requires almost zero configuration on the backend. Django doesn't even know Unpoly exists. it just serves HTML.
1. The Setup
First, we set up a standard Django project. The only "special" requirement is including the Unpoly script.
In templates/base.html, we load the library:
<!-- base -->
<head>
<!-- Unpoly CSS & JS -->
<link rel="stylesheet" href="{% static 'css/unpoly.min.css' %}">
<script src="{% static 'js/unpoly.min.js' %}"></script>
<!-- CSRF Configuration -->
<meta name="csrf-param" content="csrfmiddlewaretoken">
<meta name="csrf-token" content="{{ csrf_token }}">
<script>
up.protocol.config.csrfHeader = 'X-CSRFToken';
</script>
</head>
Crucial Step: We configure Unpoly to send the CSRF token with every request. This solves 99% of "403 Forbidden" errors people face when integrating AJAX with Django.
2. The Main Target
Unpoly needs to know what to update when a user clicks a link. By default, it updates the body, but that's inefficient because it re-renders the Navbar and Footer unnecessarily.
We define a "Main Content" area:
<!-- base.html -->
<nav class="navbar">...</nav>
<div class="container main-content" up-main>
{% block content %}
{% endblock %}
</div>
<footer>...</footer>
Adding the up-main attribute is a shorthand. It tells Unpoly: "If a link doesn't specify a target, assume they want to update this div."
3. Writing Standard Views
On the Django side, we write standard Class-Based Views.
# views.py
class PostListView(ListView):
model = Post
template_name = "blog/post_list.html"
We don't need JsonResponse. We don't need serializers. We just render the template.
When Unpoly requests this URL, it receives the entire HTML page. But because it knows it only needs to update .main-content, it parses the response, extracts that div, and swaps it into the current page.
This is Fragment Updating, and it's the core of the Page-Less experience.
Supercharging Django: Part 3 - Turbocharging Navigation
Now that we have the base setup, let's look at how we handle navigation in Page_Less.
The up-target Attribute
The most common Unpoly attribute you will use is up-target.
<a href="/blog/post-1" up-target=".main-content">Read Post</a>
When clicked:
- Unpoly intercepts the click (preventing full browser reload).
- It uses
fetchto get/blog/post-1. - Changes the browser URL (pushState).
- Swaps the inner HTML of
.main-contentwith the content from the server response.
Animation & Transitions
One major advantage of SPAs is the ability to animate between pages. Unpoly makes this trivial with up-transition.
<a href="..." up-target=".main-content" up-transition="move-left">
Next Page
</a>
In our Post List view, we use this for pagination:
<!-- post_list.html -->
<a class="page-link" href="?page=2" up-target=".post-list" up-transition="cross-fade">
Next
</a>
Notice we target .post-list instead of .main-content. This is granular updates in action. The sidebar (Search widget) doesn't flicker or reload; only the list of posts fades out and the new list fades in.
Active State Management
A classic pain point in MPAs is highlighting the current nav item.
Unpoly handles this automatically with the up-alias attribute or by simple URL matching. If the current URL matches the link's href, Unpoly adds an .up-current class to the link.
In our CSS:
.nav-link.up-current {
color: var(--md-sys-color-primary);
font-weight: bold;
}
No template logic ({% if request.path == ... %}) required!
Supercharging Django: Part 4 - Modal Magic & Layers
This is where Unpoly truly shines. Handling Modals in a traditional app usually involves:
- Hidden DOM nodes (
<div id="myModal" style="display:none">). - JavaScript to show/hide them.
- AJAX to fetch content into them.
Unpoly introduces the concept of Layers.
Authentication in a Modal
In Page_Less, we wanted Login and Signup to be accessible from anywhere without leaving the current page.
The Code
<a href="{% url 'login' %}"
up-layer="new"
up-mode="cover"
up-target=".login-form">
Login
</a>
- up-layer="new": Tells Unpoly to open a new layer (a modal/overlay) instead of swapping the current content.
- up-mode="cover": Specifies the style. "Cover" is a full-screen overlay (great for mobile), while "modal" is a centered box.
The Backend View
The LoginView doesn't know it's in a modal. It just renders accounts/login.html.
Unpoly fetches that page. Because we requested a new layer, Unpoly takes the response, strips out the <body> wrapper (mostly), and places the content into a dedicated modal container in the DOM.
Stacking Layers
You can stack layers infinitely. Page -> Modal (Login) -> Drawer (Terms of Service).
When you close a layer (up-dismiss), it destroys that specific DOM tree and returns focus to the layer below it.
Closing on Success
This is the trickiest part of modal auth: How do we close the modal and update the main page after login?
In our LoginView form submission:
<form method="post" up-submit up-target=".main-content" up-layer="root">
We added up-layer="root". This tells Unpoly: "When this form submits successfully, don't update the modal. Instead, update the ROOT layer (the background page)."
By updating the Root layer, Unpoly automatically detects that it should close the modal layer to show the updated root. The result? A user logs in, the modal vanishes, and the header updates to say "Hello, User" seamlessly.
Supercharging Django: Part 5 - Forms & Validation
Form handling in SPAs is notoriously tedious. You have to serialize data, prevent default, handle the fetch, catch 400 errors, parse the JSON errors, and manually map them back to input fields.
Unpoly restores the simplicity of Django Forms.
Server-Side Validation, Client-Side Feel
In Page_Less, when you create a post, we use the standard PostForm.
<form method="post" up-submit up-validate=".form-group">
{{ form.as_p }}
<button type="submit">Save</button>
</form>
The up-submit flow:
- Unpoly serializes the form.
- Submits via AJAX (POST).
- If the server returns 200 (Success): It updates the target (e.g., redirects to the detail view).
- If the server returns 400 (Bad Request):Django re-renders the template with
form.errors. Unpoly sees the non-200 status code (we need to ensure Django sends 400 on form errors, or Unpoly can detect the form re-render presence).Unpoly intelligently swaps the form HTML with the new HTML containing errors.
up-fail-target
Sometimes, success and failure should look different.
<form method="post"
up-target=".main-content"
up-fail-target=".card-body">
- Success: We probably redirect to a new page, so we update .main-content.
- Failure: We stay in the form. We only want to re-render the .card-body (the form container) to show errors, keeping the surrounding layout untouched.
Live Validation (up-validate)
Unpoly can validate fields while you type, using the server!
<input name="username" up-validate=".username-errors">
When the user blurs this field, Unpoly sends a request to the server with just that field's data. The server runs its validation logic (e.g., "Username already taken") and returns the response. Unpoly extracts the .username-errors div and updates the UI.
This gives you real-time server-side validation with zero custom API endpoints. You just use your regular Form View.
Supercharging Django: Part 6 - Instant Search
Search is a feature where users expect instant feedback. In a traditional Django app, hitting "Search" triggers a full page reload, which feels clunky.
In Page_Less, we implemented a live search that filters results as you type.
The Code
The implementation is shockingly simple. It lives in post_list.html.
<form action="{% url 'blog:post_list' %}"
method="get"
up-target=".post-list"
up-delay="200"
up-autosubmit>
<input type="search" name="q" value="{{ request.GET.q }}">
</form>
<div class="post-list">
{% for post in posts %}
...
{% endfor %}
</div>
Breakdown
- up-autosubmit: Tells Unpoly to submit the form whenever an input field triggers a
changeorinputevent. - up-delay="200": Debounces the input. It waits 200ms after the user stops typing before sending the request, preventing server hammer.
- up-target=".post-list": When the response comes back, Unpoly looks for the
<div class="post-list">in the returned HTML and replaces only that div.
The Backend
The backend is completely standard logic:
class PostListView(ListView):
def get_queryset(self):
query = self.request.GET.get('q')
if query:
return Post.objects.filter(title__icontains=query)
return Post.objects.all()
That's it. No JSON API. No specialized "Search View". The same view that renders the full page also powers the live search fragment.
This pattern is incredibly powerful for filters, sorting, and pagination.
Supercharging Django: Part 7 - CRUD without Chaos
Creating, Reading, Updating, and Deleting (CRUD). The bread and butter of web apps.
The Delete UX Problem
Deleting items usually requires a confirmation step.
- Old School: A separate page "Are you sure?".
- Old School JS: A generic
window.confirm()alert. - Page_Less Way: A nice confirmation Modal that handles the deletion and redirects gracefully.
Custom Delete Modal
We use a standard Django DeleteView.
class PostDeleteView(DeleteView):
template_name = "blog/post_confirm_delete.html"
success_url = reverse_lazy("blog:post_list")
In the template post_detail.html:
<a href="..." up-layer="new" up-mode="modal" up-size="small">Delete</a>
This opens the confirmation page in a small, localized modal.
Handling the Redirect
When the user clicks "Confirm Delete" inside the modal:
<form method="post" up-submit up-target="body" up-layer="root">
We target body on the root layer. Why? If we just targeted .main-content, the modal might stay open, or we might end up with the list view rendering inside the modal.
By saying up-layer="root", we tell Unpoly: "The result of this action affects the entire application state. Close this modal and refresh the underlying page."
Flash Messages
Django's messages framework is fantastic, but it relies on a new page render to display alerts.
Because Unpoly swaps fragments, we need to ensure the message container is included in the swap.
In base.html, our messages are inside .main-content. When we up-target=".main-content", the messages block is re-rendered automatically. We get green success banners for free, with no extra JavaScript code to "toast" them.
For the Delete action specifically, since we target body, the entire page refreshes, guaranteeing the message appears at the top.
Supercharging Django: Part 8 - Conclusion & UI Polish
We have built a fully functional, high-performance blog platform. But functional isn't enough it needs to look good.
Integrating Material 3 with Bootstrap
Page_Less sits on the intersection of utility (Bootstrap) and aesthetics (Material 3).
Instead of fighting Bootstrap, we embraced it for the Grid and Layout, but overrode its Tokens.
:root {
--md-sys-color-primary: #6750A4;
--bs-primary: var(--md-sys-color-primary); /* Mapping M3 to Bootstrap */
}
This allows us to use <div class="text-primary"> and get our custom M3 purple, not Bootstrap blue.
Glassmorphism
To give the app a modern feel, we added a "Glass" effect to the navbar.
.glass-effect {
background: rgba(255, 251, 254, 0.95) !important;
backdrop-filter: blur(10px);
}
This works perfectly with Unpoly's specific transition classes like up-transition="cross-fade", creating an app that feels like a native iOS/Android experience.
The Verdict: Why Use Unpoly?
After building Page_Less, the benefits of this architecture become clear:
1. Drastic Reduction in Complexity
We built a modern, interactive SPA without writing a single line of serialization logic, without managing client-side state stores (Redux/Pinia), and without a build step (Webpack/Vite). The backend code looks exactly like a Django app from 2015, but the user experience feels like 2025.
2. Native Performance
Because we aren't shipping a massive JavaScript bundle to "hydrate" the page, the First Contentful Paint is instantaneous. Unpoly's fragment swapping is lightweight and feels instant to the user.
3. SEO Out of the Box
Search engines see standard HTML. We didn't need to set up Server-Side Rendering (SSR) or complex hydration strategies. It just works.
4. Developer Happiness
We spent our time building features (Blog, Search, Auth), not fighting tools. We didn't debug JSON parsers or CORS issues. We just wrote Python and HTML.
Conclusion
Page_Less demonstrates that you don't need React, Redux, Next.js, and a team of 10 frontend engineers to build a modern, fast web application.
By leveraging HTML-Over-The-Wire with Unpoly, we kept our stack simple:
- Language: Python (Django)
- Frontend: HTML + CSS (Unpoly + Bootstrap)
- Complexity: Minimum
The server acts as the single source of truth. The browser is just a smart renderer.
This stack is perfect for:
- Solo developers
- Small to medium teams
- B2B SaaS applications
- Content-heavy sites (blogs, news)
Go forth and build Page Less apps!
Important Link
Unpoly: https://unpoly.com/
Bootstrap: https://getbootstrap.com/
Sample Repo: https://github.com/abdullafajal/page_less
If you encounter any issues or have questions about this, please comment below.