Customising admin forms
The 17 field types, helper text, multi-step flows, when to drop out of the declarative form.
The generator drops a working Create/Edit form for every resource, but the defaults are a starting point — not a finish line. This lesson is the practical guide to editing apps/admin/resources/<plural>.ts so the form renders the way you actually need: helper text, custom field types, conditional fields, validation messages, multi-step flows.
Where the form lives
Every resource page is the same two lines — a thin wrapper that passes the definition to ResourcePage:
"use client";import { ResourcePage } from "@/components/resource/resource-page";import { contactResource } from "@/resources/contacts";export default function ContactsPage() {return <ResourcePage resource={contactResource} />;}
The interesting file is apps/admin/resources/contacts.ts. That's where the columns, filters, form fields, and dashboard widgets all live. Edit it freely — nothing else regenerates over it.
Anatomy of a form field
form: {fields: [{ key: "name", label: "Full name", type: "text", required: true },{ key: "email", label: "Email", type: "text", required: true, helperText: "We'll never spam." },{ key: "phone", label: "Phone", type: "text", placeholder: "+1 555 123 4567" },{ key: "group_id", label: "Group", type: "relationship-select",required: true, relatedEndpoint: "/api/groups", displayField: "name" },],}
Every field accepts the same eight keys:
| Key | Required? | What it does |
|---|---|---|
| key | yes | JSON key sent to the API. Must match the Go struct's json tag. |
| label | yes | Human-friendly label shown above the input. |
| type | yes | One of the 17 field types listed below. |
| required | no | Shows a red star, blocks submit when empty. |
| placeholder | no | Grey hint text inside the input. |
| helperText | no | Small note rendered below the input. Use for hints. |
| defaultValue | no | Pre-fills the field when opening Create (not Edit). |
| hidden | no | Sends the field but doesn't render it (useful with defaultValue). |
The 17 form field types
These cover every common admin input. Pick the type and the form gets the right widget, the right validation, the right keyboard.
| Type | Widget | Use it for |
|---|---|---|
| text | single-line input | name, email, phone, slug, short fields |
| textarea | multi-line input | notes, plain descriptions |
| richtext | Tiptap Word-style editor | blog body, formatted content |
| number | numeric input | stock counts, ratings, percentages |
| select | dropdown | fixed enum (status, priority, role) |
| radio | radio group | short enum visible at a glance |
| checkbox | checkbox | terms acceptance |
| toggle | switch | is_active, published, featured |
| date | date picker | birthday, deadline |
| datetime | datetime picker | scheduled_at, published_at |
| image / video / file | single upload | avatar, cover photo, hero video |
| images / videos / files | multi upload | gallery, attachments |
| relationship-select | async dropdown | belongs_to (group, customer) |
| multi-relationship-select | async multi-dropdown | many_to_many (tags, roles) |
Recipe 1 — add a status dropdown to Contact
The generator left status as a plain text input (because it's a string field). Make it a select instead:
form: {fields: [{ key: "name", label: "Name", type: "text", required: true },{ key: "email", label: "Email", type: "text", required: true },{ key: "phone", label: "Phone", type: "text" },{key: "status",label: "Status",type: "select",required: true,defaultValue: "active",options: [{ label: "Active", value: "active" },{ label: "Inactive", value: "inactive" },{ label: "Archived", value: "archived" },],},],}
Recipe 2 — make the email helper friendlier and add a placeholder
{key: "email",label: "Email address",type: "text",required: true,placeholder: "you@example.com",helperText: "We'll only use this for transactional notifications.",}
Recipe 3 — image upload with a smart label
The generator emits type: "image" for URL-named string fields (avatar, cover). Take it further with custom helper text:
{key: "avatar",label: "Profile picture",type: "image",helperText: "PNG, JPG, or WebP. Up to 5 MB. Recommended: 400×400.",}
Recipe 4 — pre-fill on create with a hidden ownership field
Imagine a Note resource where every note belongs to the current admin. You don't want the admin choosing themselves from a dropdown — pre-fill it instead:
{key: "author_id",label: "Author",type: "text",hidden: true,defaultValue: "current-user-id", // or read from a context provider in your wrapper}
ResourcePage with <ResourcePage initialValues={...} /> or render the FormBuilder directly. The declarative form is for static defaults.Recipe 5 — relationship-select with a search-friendly display
Belongs-to fields default to using the related model's name column as the display label. If your model uses something else (a sku, a slug, a title):
{key: "product_id",label: "Product",type: "relationship-select",required: true,relatedEndpoint: "/api/products",displayField: "sku", // search + display by sku, not name}
Multi-step forms
Long forms (10+ fields) benefit from being split into steps. The admin form supports a steps array as an alternative to a single fields array:
form: {steps: [{title: "Basics",description: "The essentials.",fields: [{ key: "name", label: "Name", type: "text", required: true },{ key: "email", label: "Email", type: "text", required: true },],},{title: "Address",fields: [{ key: "street", label: "Street", type: "text" },{ key: "city", label: "City", type: "text" },{ key: "country", label: "Country", type: "select", options: COUNTRIES },],},{title: "Preferences",fields: [{ key: "newsletter", label: "Subscribe to newsletter", type: "toggle", defaultValue: true },],},],}
Each step renders with a progress bar at the top. The generator currently emits a single fields array — you opt into multi-step by editing the resource file by hand.
When the form isn't enough
Some flows are too custom for the declarative system — multi-step with branching, server-validated fields, payment integration, wizards that show different fields based on previous answers. In those cases the resource page is just a regular Next.js page; replace it:
"use client";import { ResourceTable } from "@/components/resource/resource-table";import { contactResource } from "@/resources/contacts";import { MyCustomCreateWizard } from "./_create-wizard";export default function ContactsPage() {return (<><ResourceTable resource={contactResource} /><MyCustomCreateWizard /></>);}
Lift the table out of ResourcePage, render it yourself, and bring your own create flow. The auto-generated list keeps working; only the Create flow changes.
Quick check
Try it
In your contact-app, customise the Contact form three ways in one sitting:
- Change the
phoneplaceholder to+1 555 123 4567. - Add a
helperTexton email saying "We'll send a verification link." - Add a new
statusdropdown with optionsactive,inactive,archived(defaultactive). You'll also need to add the column to the Go model and rungrit migrate+grit syncfirst.
Open the admin Create dialog and confirm all three changes are visible.
What's next
Now the form sends the right data. Next lesson: making the table that displays it look exactly the way you want — column formatting, badges, packed cells, filters, and the new cell render function for fully custom columns.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub