1
0
Supercharging Django with Unpoly: The Page-Less Architecture

Supercharging Django with Unpoly: The Page-Less Architecture

12 min read • Jan 6, 2026

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

  1. Zero "Glue" Code: No serializers, no JSON APIs. You send HTML, Unpoly swaps it in.
  2. Native Speed: Pages load instantly because you are swapping fragments, not reloading the full browser window.
  3. SEO Ready: Search engines see standard HTML. No complex Server-Side Rendering (SSR) hacks needed.
  4. Instant Modals: Open any Django URL in a modal with a single HTML attribute.
  5. 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:

  1. Unpoly intercepts the click (preventing full browser reload).
  2. It uses fetch to get /blog/post-1.
  3. Changes the browser URL (pushState).
  4. Swaps the inner HTML of .main-content with 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:

  1. Hidden DOM nodes (<div id="myModal" style="display:none">).
  2. JavaScript to show/hide them.
  3. 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:

  1. Unpoly serializes the form.
  2. Submits via AJAX (POST).
  3. If the server returns 200 (Success): It updates the target (e.g., redirects to the detail view).
  4. 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

  1. up-autosubmit: Tells Unpoly to submit the form whenever an input field triggers a change or input event.
  2. up-delay="200": Debounces the input. It waits 200ms after the user stops typing before sending the request, preventing server hammer.
  3. 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.

0 Comments

No comments yet.

Liked by
View all

Add a Comment

You need to be logged in to add a comment. Please log in or create an account.

Profile Background
Abdulla Fajal
Softwere Developer
New Delhi
Mar 05, 1997
Founder of this learning and blogging platform, connecting learners and experts to share knowledge, solve problems, gro… Read more
View Full Profile