Form Wizards¶
A multi-step form across several requests usually means hand-rolling step routing, stashing partial data in the session, and re-wiring all of it to support the browser back button or a branch that skips a step.
A next.forms.FormWizard carries that load.
It routes a sequence of ordinary forms across requests and finalises once on the last step.
Each step is a plain django.forms.Form or django.forms.ModelForm.
The wizard handles step routing and back-navigation, supports conditional branching through get_steps, persists per-step drafts through the configured wizard backend, and calls done with the merged cleaned data after the final step.
Mental Model¶
One wizard is one registered action.
Subclassing next.forms.FormWizard registers the class through the __init_subclass__ hook the moment Python runs the class statement, exactly like a plain form.
The action name is the snake_case of the class name, so AccessRequestWizard becomes access_request_wizard.
The scope rules match plain forms.
A wizard declared in page.py is page-scoped and keyed to its file.
A wizard declared in any other module is shared and reachable project-wide.
See Forms Overview for the full scope derivation.
The access guards also match plain forms.
Meta.login_required and Meta.permission_required on the wizard class guard its endpoint, and the guard is enforced on every step submission, not only the final one.
The keys are read through plain class-attribute lookup, so a wizard subclassing a guarded base while declaring its own Meta must extend the base Meta or re-declare the guard keys.
See Access Guards for the semantics and the inheritance rule.
A wizard also accepts the dynamic check_permissions classmethod for a per-request decision.
It is enforced per step POST, before the step form binds, so a denied step writes no storage.
A wizard has no object-level hook.
See Dynamic Permission Hooks for the hook contract.
Declaring Steps¶
Declare the ordered steps under Meta.steps as a list of (name, FormClass) tuples.
Each form class is a plain django.forms.Form or django.forms.ModelForm.
access_request_wizard¶import next.forms
from access.models import AccessRequest
from django import forms
from django.http import HttpRequest
from next.forms import redirect_to_origin
class IdentityStep(forms.ModelForm):
class Meta:
model = AccessRequest
fields = ["full_name", "email", "team"]
class ScopeStep(forms.ModelForm):
class Meta:
model = AccessRequest
fields = ["project_slug", "reason", "expires_in_days"]
class ApprovalStep(forms.Form):
"""Final step that only confirms the merged request."""
class AccessRequestWizard(next.forms.FormWizard):
class Meta:
steps = [
("identity", IdentityStep),
("scope", ScopeStep),
("approval", ApprovalStep),
]
url_param = "step"
def done(self, request: HttpRequest, cleaned_data):
AccessRequest.objects.create(**cleaned_data)
return redirect_to_origin(request)
The captions on this page mirror the repository’s audit-forms example, which configures PAGES_DIR as views and COMPONENTS_DIR as _blocks.
Under the default settings the same files live in access/pages/request/[step]/ and a _components/ folder, see Settings.
Every step form subclasses django.forms directly.
A step is not a standalone action, so a plain Django form has nothing to register and nothing to suppress.
The wizard never calls the next.forms hooks on a step, so the framework base classes buy a step nothing, see How Step Forms Differ From Standalone Forms.
A next.forms base can still serve as a step, but then it must set Meta.abstract = True: without the flag __init_subclass__ registers the step as its own form action whose default on_valid saves a partial row, and the next.W057 system check warns about the double role.
See Preventing Registration for the Meta.abstract semantics.
Meta.steps is required.
An empty or missing list triggers the next.E050 system check and the wizard is not usable.
Keep FileField and ImageField out of step forms.
Wizard storage persists each step’s cleaned_data between requests, and an uploaded file does not survive that round trip.
The next.W058 check flags a file field in a static step, collect the upload in a standalone form action instead.
Meta.url_param names the URL kwarg that carries the active step.
It defaults to "step", so a route segment of [step] works with no further configuration.
Per-step drafts persist through the configured FORM_WIZARD_BACKEND, which the project sets once for every wizard.
See Wizard Backend for the backend contract and its options.
The done Method¶
done runs once after the last step validates.
It receives request and the merged cleaned_data of every stored step, so the keys from each step form are flattened into one mapping.
For a wizard whose steps are ModelForms over a single model, the merged dict maps straight onto a model constructor.
A field declared by two steps merges to the last stored value.
Call self.get_cleaned_data_for_step(step) when the per-step value matters: it returns that step’s stored dict, or None when the step has not been submitted.
def done(self, request: HttpRequest, cleaned_data):
AccessRequest.objects.create(**cleaned_data)
return redirect_to_origin(request)
done is required.
The base implementation raises NotImplementedError, so every wizard subclass must override it.
Return any HttpResponse, most often a redirect away from the wizard.
A field declared by two steps is statically flagged by the next.W059 check, since the merge silently keeps the last value.
Meta.success_message works on a wizard too: the message is flashed once, after done succeeds, interpolated over the merged step data, with get_success_message as the dynamic override.
See Success Feedback for the message contract.
Keep done idempotent.
A retried final submission can run it again, so guard against creating a duplicate row.
Make done safe to run twice with the same data, for example by using get_or_create instead of create.
The backend performs an unlocked read-modify-write, so concurrent submissions from two browser tabs overwrite each other’s step data.
Last write wins is the deliberate design for a single linear flow.
An idempotent done keeps the final write correct under retries, which is the case that matters.
A flow that must serialise concurrent tabs needs a custom backend that locks or compares and swaps the stored bucket.
done Is Dependency-Injected¶
The dispatcher resolves done through the same injector as an action handler or on_valid.
The classic signature done(self, request: HttpRequest, cleaned_data) reads request through its HttpRequest annotation, the same way a context callable does, so an unannotated request resolves to None.
Only cleaned_data is reserved by parameter name.
cleaned_data is reserved the way form is on a handler, so it always carries the merged step data and wins over a provider or a URL kwarg of the same name.
Beyond the reserved names, done declares markers and named dependencies like any injected callable, and URL kwargs resolve by parameter name.
from next.deps import Depends
def done(self, request: HttpRequest, cleaned_data, tenant=Depends("active_tenant")):
AccessRequest.objects.create(tenant=tenant, **cleaned_data)
return redirect_to_origin(request)
self.url_kwargs still carries the captured URL values for code that prefers an explicit read.
A wizard module falls under the DI annotation rule: do not add from __future__ import annotations to a module whose done the resolver inspects, see Dependency Injection.
The done Return Value¶
The return value of done follows the same coercion as an action handler.
An
HttpResponsesubclass is sent as is. A redirect is the usual choice.A string becomes an
HttpResponsebody with status 200.A value with a truthy
urlattribute becomes anHttpResponseRedirectto that URL.Nonecoerces to a success response that re-renders the origin page.
Warning
Returning None from done still finalises the wizard.
The dispatcher coerces None into a success re-render of the origin page with status 200, so the stored drafts are cleared and wizard_completed fires.
The user is left on the last step with no confirmation, so return an explicit redirect away from the wizard.
How Step Forms Differ From Standalone Forms¶
The wizard drives a step through a different path than a standalone form action, which is why a plain Django form is the canonical step base.
Two hooks that fire for a standalone next.forms action never fire for a step.
get_initialis not called on POST.The dispatcher builds the step form as
form_class(request.POST, files, **get_form_kwargs(step)). It never callsget_initialon the step class during validation. Prefilling a step instead flows through the saved draft, which the wizard injects asinitialwhen it builds the unbound form for a GET. Pass cross-step values throughget_form_kwargson the wizard, not throughget_initialon the step.on_validis never called.A standalone form runs
on_validafter it validates. A step does not. The wizard saves the cleaned data, advances to the next step, and runsdoneonce after the final step. Put per-step side effects nowhere, and put the single finalising write indone.
A step built on a next.forms base that defines get_initial or on_valid sees neither method run inside the wizard.
Both are silently inert on a step class, so subclassing next.forms buys a step nothing and costs the Meta.abstract flag.
Rendering¶
Render the wizard with the same {% form %} block tag a plain form uses.
{% form "access_request_wizard" %}
{{ form.as_p }}
<button type="submit">
{% if wizard.is_last %}Submit{% else %}Continue{% endif %}
</button>
{% endform %}
Inside the block the tag publishes two variables.
form is the current step’s form, unbound on a GET and prefilled from the saved draft when the step was visited before.
wizard is the FormWizard instance, which exposes the navigation helpers below.
Wizard Template API¶
The wizard variable carries the methods used to build progress indicators and navigation.
The zero-argument methods are auto-called by Django templates, so a template writes {{ wizard.current_step }} without parentheses.
Method |
Returns |
Notes |
|---|---|---|
|
The active step name, read from the URL kwarg and defaulting to the first step. |
Zero-argument, usable as |
|
The ordered step names for this request, after any |
Zero-argument, iterable as |
|
|
Zero-argument. |
|
|
Zero-argument. |
|
The names of steps that already have a saved draft. |
Zero-argument. |
|
The merged cleaned data of every saved step. |
Zero-argument. |
|
The saved cleaned data for |
Takes an argument, so call it from a |
|
The page URL for |
Takes an argument, so call it from a |
A progress bar reads the step status in Python and iterates the precomputed list in the template.
from typing import Any
from next.components import component
from next.forms import FormWizard
@component.context("steps")
def steps(wizard: FormWizard) -> list[dict[str, Any]]:
current = wizard.current_step()
completed = set(wizard.completed_steps())
return [
{
"key": name,
"url": wizard.goto(name),
"status": _status(name, current, completed),
}
for name in wizard.step_names()
]
def _status(key: str, current: str, completed: set[str]) -> str:
if key == current:
return "current"
if key in completed:
return "saved"
return "pending"
<nav>
{% for step in steps %}
<a href="{{ step.url }}" data-status="{{ step.status }}">
{{ step.key }}
</a>
{% endfor %}
</nav>
Conditional Steps¶
Override get_steps to choose the step list from the data gathered so far.
The hook reads the accumulated data through self.get_all_cleaned_data() and returns the same [(name, FormClass), ...] shape as Meta.steps.
def get_steps(self):
steps = [("identity", IdentityStep), ("scope", ScopeStep)]
if self.get_all_cleaned_data().get("expires_in_days", 0) > 7:
steps.append(("approval", ApprovalStep))
return steps
When get_steps is not overridden it returns Meta.steps unchanged.
The navigation helpers, the current-step resolution, and the final-step detection all read from get_steps, so a conditional list flows through routing without extra wiring.
Cross-Step Inputs¶
Override get_form_kwargs to pass extra constructor arguments into a step form.
The hook receives the name of the step being built and returns a dict of keyword arguments for that step’s form constructor.
Read the merged cleaned data through self.get_all_cleaned_data(), or one step’s data through self.get_cleaned_data_for_step(step), to derive the kwargs.
def get_form_kwargs(self, step=None):
if step == "approval":
return {"reviewer_pool": teams_for(self.get_all_cleaned_data().get("team"))}
return {}
A step form that accepts reviewer_pool reads it in its own __init__ to build a field choice list.
The default get_form_kwargs returns an empty dict, so steps that need nothing extra require no override.
Signals¶
The wizard emits wizard_step_submitted after each step validates and wizard_completed after done returns a success response for the final step.
Both are sent with the wizard class as the sender, so a receiver connected with sender=MyWizard observes one wizard only.
An error response from done, status 400 or above, skips wizard_completed and keeps the saved drafts for retry.
See Form Signals for the payloads and the receiver-wiring pattern.
System Checks¶
The next.E050 and next.E051 checks guard the steps declaration and the wizard backend configuration.
next.E054 errors when a page-scoped wizard’s page path has no [url_param] directory, so the route never captures the step kwarg and the wizard cannot advance past its first step.
Add a [step] route segment or point Meta.url_param at the captured kwarg.
next.W056 warns when wizards are registered and the configured backend needs Django sessions while django.contrib.sessions is not installed.
next.W057 warns when a static Meta.steps form class is also registered as a standalone action, which the plain-Django-form step pattern avoids.
next.W058 warns when a static step declares a FileField or ImageField, whose uploads do not survive the draft round trip.
next.W059 warns when two static steps declare the same field name, where get_all_cleaned_data() keeps the last value and get_cleaned_data_for_step gives per-step access.
The static-step checks inspect Meta.steps only, a get_steps override is not visible to them.
See System Checks for their conditions, and run uv run python manage.py check after editing a wizard or its backend.
See Also¶
See also
Wizard Backend for the backend contract and the session-backed default.
Forms Overview for auto-registration and scope.
ModelForms for standalone ModelForm actions and the hooks a wizard step bypasses.
Form Signals for the wizard signals.
Build a Multi-Step Wizard for a step-by-step recipe.
File Router for the [step] route segment.