Use ModelForm for CRUD#
Problem#
You want create and edit pages for a model with as little glue code as possible.
Solution#
Declare one next.forms.ModelForm subclass in a shared module such as notes/forms.py.
Add Meta.instance_from_url so the edit page loads its row from the captured URL kwarg.
The same class drives the create page, where the kwarg is absent and the form renders unbound.
Walkthrough#
Declare the form once#
The class lives outside page.py, so it registers with shared scope.
One action name and one action URL serve both pages.
Autodiscovery imports notes/forms.py on startup, so neither page module imports it.
import next.forms
from notes.models import Note
class NoteEditForm(next.forms.ModelForm):
class Meta:
model = Note
fields = ["slug", "title", "body"]
instance_from_url = "slug"
A copy declared in each page.py would be page-scoped instead, keeping the shared action name but giving every per-file registration its own action URL.
See ModelForms for that distinction and Actions for the scope rules.
Edit page#
The edit page lives under a route that captures the lookup field.
Here the route segment is [slug], so the captured kwarg is slug.
{% form "note_edit_form" %}
{{ form.slug }}
{{ form.title }}
{{ form.body }}
<button type="submit">Save</button>
{% endform %}
The default get_initial loads Note.objects.get(slug=<captured slug>) through get_object_or_404().
The default on_valid calls self.save() and redirects to the origin page.
No handler, no hidden lookup field, and no second lookup are needed.
Meta.success_url redirects the successful submission to another page instead, and next.urls.page_reverse_lazy builds that value from a page path.
Meta.success_message flashes a message through Django’s messages framework, interpolated over the cleaned data.
See Success feedback for both options.
The {% form %} tag resolves the action by name, opens the <form> element, injects the CSRF token, and publishes form inside the block.
It also emits a hidden _next_form_origin field with the page URL, so the dispatcher recovers the captured slug by resolving that path and the submission re-attaches to the same row.
Create page#
The create page renders the same form on a route with no captured kwarg.
{% form "note_edit_form" %}
{{ form.slug }}
{{ form.title }}
{{ form.body }}
<button type="submit">Create</button>
{% endform %}
With no slug in the URL, get_initial returns an empty dict and the form renders fresh.
self.save() then inserts a new row.
URL names follow the page_{name} convention where path segments are joined with underscores and captured-parameter brackets are dropped.
See File router for the full naming rules.
Verification#
Walk through the flow once. Create a note, then edit it, and confirm the index reflects each step.
A test asserts the same flow with NextClient.
from next.testing.client import NextClient
from notes.models import Note
def test_crud_flow(db) -> None:
client = NextClient()
client.post_action("note_edit_form", {"slug": "intro", "title": "Intro", "body": ""})
note = Note.objects.get(slug="intro")
client.post_action(
"note_edit_form",
{"slug": "intro", "title": "Intro v2", "body": ""},
origin=f"/notes/edit/{note.slug}/",
)
assert Note.objects.get(pk=note.pk).title == "Intro v2"
The first post_action mimics the create page.
With no origin there is no captured kwarg, get_initial returns an empty dict, and the bound form inserts a new row.
The second passes origin, which fills the _next_form_origin field the {% form %} tag emits.
Resolving the edit-page path yields the slug kwarg, instance_from_url loads the existing row, and the save updates it.
The recovered kwargs come through the URL converters of the resolved route, so a [int:id] kwarg arrives as an integer on both the initial render and the re-render, while a slug stays a string.
See also#
See also
ModelForms for the ModelForm topic guide.
Form templates for the {% form %} tag.
Validation and re-render for the re-render flow.