There’s a moment in every business app build where the form gets complicated.
The model has twenty fields. Some only matter for certain order types. Some need to be filled in sequence — you can’t enter delivery details until you’ve chosen delivery as the order type. The UX needs steps, validation between steps, a summary before submit.
The traditional answer is to write a multi-step form component. Wire up state management. Handle validation per step. Build the skip logic. Connect the submit handler. It’s not conceptually hard, but it’s a lot of code, and it’s code that’s tightly coupled to this specific form in this specific app.
The PageDef answer is different: declare the steps in the config, and let the framework build the form.
The wizard key
A PageDef with a wizard looks like this:
module.exports = {
id: 'order',
model: 'Order',
// ... columns, actions, etc.
wizard: [
{ id: 'type', label: 'Order Type', fields: ['order_type', 'covers'] },
{ id: 'details', label: 'Details', fields: ['table_id', 'notes'],
skipWhen: { field: 'order_type', value: 'takeaway' } },
{ id: 'items', label: 'Items', fields: [], widget: 'line-items' },
{ id: 'confirm', label: 'Confirm', fields: [], summary: true },
],
};
Four steps. One is skipped for takeaway orders. One is a custom widget. One is a summary of everything collected. This declaration is the complete specification of the form’s behavior.
The framework renders the multi-step UI, handles step navigation, evaluates skipWhen conditions, injects a widget in the third step, and builds the summary from accumulated data. The developer wrote twelve lines of config.
What the framework actually does
The formContext method in PageDefRenderer transforms the wizard array into wizardSteps — a normalized structure where each step has its fields resolved to full field objects, its index set, and its skip condition, widget, and summary flag attached.
Alpine.js handles the client-side state: current step index, accumulated form data, whether each step is skipped, validation per step. The initWizard() function is injected into the form template and manages all transitions.
The next() function validates the current step, marks it complete, accumulates the field values into formData, and advances — skipping any step whose skipWhen condition is true given the current formData. back() reverses the same logic. submit() injects all accumulated values as hidden inputs before the native form submit fires.
None of this logic is custom to the Order form. It’s generic over any PageDef that has a wizard key. Adding a wizard to a different model means writing a different twelve lines of config.
Why this matters for AI-assisted development
When I build a feature like wizard forms as a framework capability rather than a one-off component, the AI agents that work with this codebase get something valuable: a stable, documented interface for expressing complex UI behavior.
An agent asked to “add a wizard form to the Product model” doesn’t need to reason about Alpine state management, step validation, or skip logic. It reads the PageDef spec, writes the wizard array, and the framework handles the rest.
This is the same principle as the NLP contracts system, applied to UI: reduce the space of decisions an AI agent needs to make. The more behavior the framework handles declaratively, the more precisely you can describe what you want.
“Add a step that only shows for delivery orders” is a clear, testable requirement. It maps directly to skipWhen: { field: 'order_type', value: 'delivery' }. No ambiguity. No implementation decisions. The AI writes the config; the framework builds the UI.
The constraint that made it work: Nunjucks
Building this exposed a real constraint in the template layer. Nunjucks — the server-side template engine DarJS uses — doesn’t support inline conditional expressions in the same way JavaScript does.
{# This fails: #}
{{ 'number' if f.type == 'integer' or f.type == 'decimal' else 'text' }}
{# This works: #}
{% if f.type == 'integer' or f.type == 'decimal' %}
{% set inputType = 'number' %}
{% elif f.type == 'date' %}
{% set inputType = 'date' %}
{% else %}
{% set inputType = 'text' %}
{% endif %}
When you’re generating template code from a PageDef, these constraints matter. The generated template has to be valid Nunjucks — which means explicit set blocks instead of inline ternaries, if blocks instead of chained filters.
This is the kind of constraint that doesn’t appear in documentation. It surfaces when you try to generate a complex template from a config and the template engine rejects valid-looking syntax. Knowing the constraint exists is worth more than knowing the workaround.
The completeness question
The wizard handles the complex case. But PageDef forms handle the simple case too — a flat form for a model with few fields doesn’t need a wizard. The framework detects this automatically: if pageDef.wizard is not set and the model has more than six fields, it auto-chunks into steps of three. If the model has six or fewer fields, it renders a single-page form.
Three behaviors from one config key: single-page form, auto-wizard, named wizard. The behavior scales with the declaration. You only add complexity when the form requires it.
The pattern worth carrying forward: declarative config should express what the UI does, not how it does it. The moment you’re writing Alpine state management directly in your app code, you’re at the wrong level of abstraction. Push it into the framework, expose a config key, write a spec, write tests. Then every future form benefits from the work.
Read time: ~5 min