v0.25.0 - Alpha

HyperDjango brings high-performance, hypermedia-driven interactivity to your Django templates without the JavaScript fatigue.

pip install hyperdjango

Todo Management

Full CRUD cycles with server-side state and instant partial updates.

template.html
{% include "partials/todo_stats.html" %}
{% include "partials/todo_empty.html" %}
<ul id="todo-list">
  {% for todo in todos %}
    {% include "partials/item.html" with todo=todo %}
  {% endfor %}
</ul>
partials/item.html
<li id="todo-{{ todo.id }}">
  <input
    type="checkbox"
    @change="$action('toggle_todo', { id: '{{ todo.id }}' })"
  />
  <span>{{ todo.title }}</span>
  <button @click="$action('delete_todo', { id: '{{ todo.id }}' })">
    Delete
  </button>
</li>
+page.py
from hyperdjango import HyperView
from hyperdjango.actions import Delete, HTML, Toast, action


class PageView(HyperView):
    def todo_stats(self):
        todos = Todo.objects.order_by("id")
        completed = todos.filter(done=True).count()
        return {
            "todo_total": todos.count(),
            "todo_completed": completed,
            "todo_active": todos.count() - completed,
        }

    def get(self, request, **params):
        todos = Todo.objects.order_by("id")
        return {"todos": todos, **self.todo_stats()}

    @action
    def add_todo(self, request, title=""):
        todo = Todo.objects.create(title=title, done=False)
        return [
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/item.html",
                    context_updates={"todo": todo},
                ),
                target="#todo-list",
                swap="append",
            ),
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/todo_stats.html",
                    context_updates=self.todo_stats(),
                ),
            ),
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/todo_empty.html",
                    context_updates={"has_todos": True},
                ),
            ),
        ]

    @action
    def toggle_todo(self, request, id=None):
        todo = Todo.objects.get(id=id)
        todo.done = not todo.done
        todo.save()
        return [
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/item.html",
                    context_updates={"todo": todo},
                ),
            ),
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/todo_stats.html",
                    context_updates=self.todo_stats(),
                ),
            ),
            Toast(payload={
                "type": "info",
                "title": "Updated",
                "message": "Todo state changed.",
            }),
        ]

    @action
    def delete_todo(self, request, id=None):
        todo = Todo.objects.get(id=id)
        todo.delete()
        return [
            Delete(target=f"#todo-{id}"),
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/todo_stats.html",
                    context_updates=self.todo_stats(),
                ),
            ),
            HTML(
                content=self.render(
                    request=request,
                    relative_template_name="partials/todo_empty.html",
                    context_updates={"has_todos": Todo.objects.exists()},
                ),
            ),
            Toast(payload={
                "type": "warning",
                "title": "Deleted",
                "message": "Todo removed.",
            }),
        ]
Live Interactive Demo
Total
3
Active
2
Done
1
  • Configure HyperDjango
  • Implement partial swaps
  • Deploy to production

Type-ahead Search

Debounced input events triggering live server-side filtering.

template.html
<div x-data="{ q: '' }">
  <p hyper-loading="live-search">
    Searching...
  </p>

  <div class="relative">
    <input
      x-model="q"
      @input.debounce.300ms="$action('search', { q }, { key: 'live-search' })"
    />
    <div class="search-loading-frame" hyper-loading="live-search"></div>
  </div>

  {% include "partials/search_results.html" %}
</div>
partials/search_results.html
<ul id="search-results-demo">
  {% for result in search_results %}
    <li>{{ result }}</li>
  {% empty %}
    {% if q %}
      <li>No matches found for "{{ q }}"</li>
    {% endif %}
  {% endfor %}
</ul>
+page.py
from hyperdjango import HyperView
from hyperdjango.actions import HTML, action


class PageView(HyperView):
    def get(self, request, **params):
        return {"search_results": []}

    @action
    def search(self, request, q=""):
        results = SearchDocument.objects.filter(title__icontains=q)[:8]
        html = self.render(
            request=request,
            relative_template_name="partials/search_results.html",
            context_updates={
                "search_results": results,
                "q": q,
            },
        )
        return [HTML(content=html)]
Live Interactive Demo

Searching...

Agent Streaming

A real chat flow: user prompt, streamed tool work, then an assistant answer that types into the thread.

template.html
<section x-data="{ prompt: '' }">
  <div id="agent-thread-demo" class="agent-chat-thread">
    {% include "partials/agent_log_empty.html" %}
  </div>

  <label class="block mb-2">Prompt</label>
  <form
    class="agent-chat-composer"
    @submit.prevent="if (!prompt.trim()) return; $action('run_agent_stream', { prompt }, { method: 'POST', key: 'agent-stream' }); prompt = ''"
  >
    <div class="agent-chat-composer-field">
      <input x-model="prompt" />
    </div>
    <button class="agent-chat-send">Send</button>
  </form>
</section>
partials/agent_user_message.html
<div class="agent-chat-row agent-chat-row-user">
  <div class="agent-chat-bubble agent-chat-bubble-user">
    <div class="agent-chat-label">you</div>
    <div class="agent-chat-text">{{ prompt }}</div>
  </div>
</div>
partials/agent_log_shell.html
<div id="agent-tool-message" class="agent-chat-bubble agent-chat-bubble-tool agent-chat-bubble-tool-live">
  <div id="agent-log-text" class="agent-chat-tool-text"></div>
</div>
partials/agent_log_chunk.html
<span class="agent-log-chip">[{{ kind }}] {{ message }}</span>
partials/agent_assistant_shell.html
<div class="agent-chat-row agent-chat-row-agent">
  <div class="agent-chat-bubble agent-chat-bubble-agent">
    <div class="agent-chat-label">assistant</div>
    <div id="agent-response-demo" class="agent-chat-text"></div>
  </div>
</div>
entry.ts
window.addEventListener("agent:scroll", () => {
    queueMicrotask(() => {
        const thread = document.getElementById("agent-thread-demo");
        if (!(thread instanceof HTMLElement)) return;

        thread.scrollTop = thread.scrollHeight;
    });
});
+page.py
from hyperdjango import HyperView
from hyperdjango.actions import Event, HTML, Toast, action


class PageView(HyperView):
    @action
    def run_agent_stream(self, request, prompt=""):
        prompt = (
            prompt.strip()
            or "Explain how HyperDjango streams server events to the UI."
        )

        yield HTML(content="", target="#agent-thread-demo", swap="inner")
        yield HTML(
            content=self.render(
                request,
                "partials/agent_user_message.html",
                {"prompt": prompt},
            ),
            target="#agent-thread-demo",
            swap="append",
        )
        yield Event(name="agent:scroll", payload={})
        yield HTML(
            content=self.render(
                request,
                "partials/agent_log_shell.html",
            ),
            target="#agent-thread-demo",
            swap="append",
        )
        yield Event(name="agent:scroll", payload={})

        for kind, message in [
            ("plan", f"Mapping request: {prompt}"),
            ("tool", "Scanning partials and action handlers"),
            ("write", "Preparing streamed UI patches"),
            ("test", "Running checks before answering"),
        ]:
            yield HTML(
                content=self.render(
                    request,
                    "partials/agent_log_chunk.html",
                    {"kind": kind, "message": message},
                ),
                target="#agent-tool-text",
                swap="inner",
            )
            yield Event(name="agent:scroll", payload={})

        yield HTML(
            content=self.render(
                request,
                "partials/agent_log_shell_done.html",
            ),
            target="#agent-tool-message",
        )
        yield HTML(
            content=self.render(
                request,
                "partials/agent_log_chunk.html",
                {
                    "kind": "test",
                    "message": "Checks passed. Starting final response stream",
                },
            ),
            target="#agent-log-text",
            swap="inner",
        )
        yield Event(name="agent:scroll", payload={})

        yield HTML(
            content=self.render(
                request,
                "partials/agent_assistant_shell.html",
            ),
            target="#agent-thread-demo",
            swap="append",
        )
        yield Event(name="agent:scroll", payload={})

        response = (
            "I parsed your prompt and built a plan. "
            "Then the tool phase streamed into a single evolving status bubble. "
            "After the checks passed, the final answer streamed into the assistant bubble."
        )
        chunks = [segment + " " for segment in response.split()]

        if chunks:
            chunks[-1] = chunks[-1].rstrip()

        for chunk in chunks:
            yield HTML(
                content=self.render(
                    request,
                    "partials/agent_response_chunk.html",
                    {"chunk": chunk},
                ),
                target="#agent-response-demo",
                swap="append",
            )
            yield Event(name="agent:scroll", payload={})

        yield Toast(
            payload={
                "type": "success",
                "title": "Agent finished",
                "message": "Stream complete.",
            }
        )
Live Interactive Demo
Ask anything to start a streamed agent run.

Inline Editing

Transform static content into interactive forms with a single action.

template.html
<div id="inline-editor-demo" x-data="{ text: '{{ inline_text }}' }">
  {% if editing %}
    <form @submit.prevent="$action('save_inline', { text })">
      <input x-model="text" @keydown.escape="$action('save_inline', {})" />
    </form>
  {% else %}
    <button @click="$action('edit_inline')">{{ inline_text }}</button>
  {% endif %}
</div>
+page.py
from hyperdjango import HyperView
from hyperdjango.actions import HTML, action


class PageView(HyperView):
    def get(self, request, **params):
        profile = Profile.objects.get(pk=1)
        return {"inline_text": profile.bio, "editing": False}

    @action
    def edit_inline(self, request):
        profile = Profile.objects.get(pk=1)
        html = self.render(
            request=request,
            relative_template_name="partials/inline_editor.html",
            context_updates={
                "inline_text": profile.bio,
                "editing": True,
            },
        )
        return [HTML(content=html)]

    @action
    def save_inline(self, request, text=""):
        profile = Profile.objects.get(pk=1)
        profile.bio = text or profile.bio
        profile.save()
        html = self.render(
            request=request,
            relative_template_name="partials/inline_editor.html",
            context_updates={
                "inline_text": profile.bio,
                "editing": False,
            },
        )
        return [HTML(content=html)]
Live Interactive Demo

File Upload

Upload progress can stream from the browser while the server returns a normal HyperDjango action response.

template.html
<form id="upload-form-demo" x-data="uploadDemo()" method="POST" enctype="multipart/form-data">
  <label class="upload-picker">
    <span>Choose file</span>
    <span x-text="filename"></span>
    <input type="file" name="upload" class="sr-only" @change="reset('#upload-form-demo')" />
  </label>
  <button
    type="button"
    @click="reset('#upload-form-demo'); $action('upload_demo', {}, { form: '#upload-form-demo', key: 'upload-demo', onUploadProgress() {} })"
  >
    Upload
  </button>

  <div class="relative border border-black/10 h-3">
    <div :style="`width:${progress}%`"></div>
  </div>

  {% include "partials/upload_result.html" %}
</form>
entry.ts
Alpine.data("uploadDemo", () => ({
    progress: 0,
    status: "idle | 0%",
    filename: "No file chosen",

    init() {
        window.addEventListener("hyper:uploadProgress", (event) => {
            if (event.detail.key !== "upload-demo") return;

            const percent = Math.round((event.detail.progress ?? 0) * 100);
            this.progress = percent;
            this.status = `uploading | ${percent}%`;
        });

        window.addEventListener("hyper:afterRequest", (event) => {
            if (event.detail.key !== "upload-demo") return;

            this.progress = event.detail.ok ? 100 : 0;
            this.status = event.detail.ok ? "done | 100%" : "error | 0%";
        });
    },

    reset(formSelector) {
        const form = document.querySelector(formSelector);
        const input = form?.querySelector('input[type="file"][name="upload"]');
        const hasFile = !!input?.files?.length;

        this.filename = input?.files?.[0]?.name || "No file chosen";
        this._expectUpload = hasFile;
        this.progress = 0;
        this.status = hasFile ? "starting | 0%" : "error | 0%";
    },
}));
+page.py
from hyperdjango import HyperView
from hyperdjango.actions import HTML, Toast, action


class PageView(HyperView):
    @action
    def upload_demo(self, request, **kwargs):
        upload = request.FILES.get("upload")

        if upload is None:
            html = self.render(
                request=request,
                relative_template_name="partials/upload_result.html",
                context_updates={
                    "error": "Choose a file before uploading.",
                    "uploaded_name": "",
                    "uploaded_size_kb": "",
                    "content_type": "",
                },
            )
            return [HTML(content=html, target="#upload-result-demo")]

        html = self.render(
            request=request,
            relative_template_name="partials/upload_result.html",
            context_updates={
                "uploaded_name": upload.name,
                "uploaded_size_kb": round(upload.size / 1024),
                "content_type": upload.content_type,
                "error": "",
            },
        )
        return [
            HTML(content=html, target="#upload-result-demo"),
            Toast(payload={"type": "success", "title": "Upload complete", "message": f"Received {upload.name}."}),
        ]
Live Interactive Demo
Uploaded file details will appear here.

Alpine Signals

Update Alpine state directly from the server without swapping any HTML.

template.html
<section x-data="{ count: 0, status: 'Idle' }">
  <p>Count: <span x-text="count"></span></p>
  <p x-text="status"></p>
  <button @click="$action('increment_signal_demo', { count })">
    Increment Signal
  </button>
</section>
+page.py
from hyperdjango import HyperView
from hyperdjango.actions import Signal, action


class PageView(HyperView):
    @action
    def increment_signal_demo(self, request, count=0):
        next_count = int(count) + 1
        return [
            Signal(name="count", value=next_count),
            Signal(name="status", value=f"Synced from server at count {next_count}"),
        ]
Live Interactive Demo
Count
Status
Capabilities

What HyperDjango Ships

HyperDjango keeps the server in control, but still gives you the UI primitives needed for modern interactive apps.

Server-driven UI

HTML Patches

Replace a full panel, patch one row, append to a feed, or delete a node outright with `HTML(...)` and `Delete(...)`.

inner / outer / append / prepend / delete
Routing

File-Based Pages

Define a route with `+page.py`, compose it with partials, and keep your URL structure obvious from the filesystem.

Realtime

SSE Streaming

Long-lived actions can stream many updates in sequence: tool output, live feeds, test phases, and token-like response chunks.

Alpine Integration

Signals Without Swaps

Send `Signal(...)` updates straight into Alpine state when the UI only needs fresh values, not a new HTML fragment.

count -> signal
status -> signal
theme -> signal
filters -> signal
Browser Feedback

Uploads, Toasts, Events

Track upload progress in the browser, dispatch custom events, and surface success or error states with server-driven toasts.

hyper:uploadProgress Toast(...) Event(...)
Navigation

Redirect + History

Push URLs, replace URLs, or redirect outright as part of the same action response that updates the UI.

Progressive Enhancement

Alpine First

HyperDjango speaks Alpine fluently: actions, signals, loading states, and direct browser-side feedback all fit cleanly.

Runtime

Load JS On Demand

Ship JavaScript only when a route truly needs it, while keeping the default interaction model server-first and partial-driven.

Execution Model

Server-First by Default

The common path stays simple: actions return HTML, signals, events, redirects, or toasts, and the browser applies them without a client-side SPA runtime.

route-level JS loading
partial-first responses
server-first navigation
Why HyperDjango

Server Power, Modern UI

HyperDjango gives you the interaction model developers want from modern frontend tools without making Django give up control of rendering, state, or flow.

Instead of a SPA

Keep Django in Charge

You can ship rich interactivity without moving routing, rendering, and business logic into a separate client runtime.

Instead of Hand Wiring

Return Intent, Not Boilerplate

Actions return `HTML`, `Delete`, `Signal`, `Event`, or `Toast` responses directly, so the server describes outcomes instead of micromanaging the browser.

Instead of Giving Up Speed

Progressive by Default

Load JavaScript only where it helps, stream responses when it matters, and keep the common path simple and fast.