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.
Picking how the form opens — formView
As of v3.31.17, every resource has three ways to present its Create / Edit form. Pick the one that fits the shape of your data:
formView | Renders as | Best for |
|---|---|---|
| "sheet" (default) | Right drawer on desktop, bottom sheet on mobile | Long forms, lots of fields, multi-line textareas |
| "modal" | Centered dialog over a backdrop | Short forms (1-6 fields), focused single-task flows |
| "page" | Dedicated route, full page | Very long forms, anything that needs URL state or shareable links |
| "modal-steps" | Sheet with step navigation | Wizard inside a sheet |
| "page-steps" | Full-page wizard | Multi-step flows you want to bookmark mid-way |
export const contactResource = defineResource({name: "Contact",slug: "contacts",// ...formView: "modal", // ← centered dialog (was "sheet" by default)table: { /* ... */ },form: { /* ... */ },});
"modal" value rendered as a sheet. If you had formView: "modal" set explicitly and want the original behavior, switch to "sheet". Resources without formView still default to sheet — nothing breaks on its own.Anatomy of a form field
form: {fields: [{ key: "name", label: "Full name", type: "text", required: true },{ key: "email", label: "Email", type: "text", required: true, description: "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 18 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). |
| colSpan | no | 1 or 2 — number of columns the field spans in a two-column layout. |
| prefix / suffix | no | Inline addons shown next to text/number inputs (e.g. "$", ".com"). |
Type-specific keys layered on top: options (select / radio), min / max / step (number), rows (textarea), relatedEndpoint / displayField / relationshipKey (relationship-select), accepts / maxSizeMB / dropzone / progress (file / files).
The 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",description: "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",description: "PNG, JPG, or WebP. Up to 5 MB. Recommended: 400×400.",}
Recipe 4 — pre-fill on create with a default value
Imagine a Note resource where every note starts in draft status. Pre-fill the dropdown with a sensible default so the operator only changes it if they need to:
{key: "status",label: "Status",type: "select",required: true,defaultValue: "draft",options: [{ label: "Draft", value: "draft" },{ label: "Published", value: "published" },{ label: "Archived", value: "archived" },],}
BeforeCreate hook that fills AuthorID from the request context. The frontend never has to know.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}
Form groups + per-group PATCH save (v3.31.18+)
Long Update views — a Product with 20+ fields, an Invoice with nested billing/shipping/notes blocks — get tedious when every Save rewrites the whole record. Define form.groups instead and each group on the Update page renders as its own Card with its own Save button. Each Save calls PATCH /api/<plural>/:id with only that group's fields.
export const productResource = defineResource({// ...formView: "page", // groups only render on page-view editsform: {fields: [/* the flat field list — still required */],groups: [{title: "Basics",description: "Name, price, and SKU. Required at create.",fields: ["name", "sku", "price"],scope: "both", // shown in Create and Update},{title: "Inventory",fields: ["stock_quantity", "reorder_threshold"],scope: "update", // hidden on Create; cards-only on Update},{title: "Marketing",description: "SEO + listing copy. Edit after launch.",fields: ["meta_title", "meta_description", "tags"],scope: "update",},],},});
The flow:
- Create shows only the groups with
scope: "both"or"create". Operators enter the minimum required and click Create. The record is saved with the standardPOST. - Update (visit
/resources/products?action=edit&edit=<id>) shows each"both"or"update"group as a separate Card. Edit one section, click Save — only that section's fields PATCH.
Patch handler whitelists writable columns and ignores anything else, so you can't accidentally PATCH a UUID or timestamp from the client.The "create-and-update" pattern
Pair scope: "create" with everything else as scope: "update" to ship a frictionless Create flow with detailed editing deferred:
groups: [// Create asks for just three things.{ title: "Quick start", fields: ["title", "price"], scope: "create" },// Everything else lives on the Update page as cards.{ title: "Description", fields: ["description", "body"], scope: "update" },{ title: "Media", fields: ["cover", "gallery"], scope: "update" },{ title: "SEO", fields: ["meta_title", "meta_description"], scope: "update" },]
Multi-step forms
Long forms (10+ fields) benefit from being split into steps. Pair the existing form.fields[] array with a form.steps[] array: every field stays defined once in fields[], and each step lists the field keys it owns. Single source of truth per field (type, label, validation, default), with the steps just slicing them into pages.
form: {// Every field is defined exactly once -- type, label,// validation rules, defaults, etc.fields: [{ key: "name", label: "Name", type: "text", required: true },{ key: "email", label: "Email", type: "text", required: true },{ key: "street", label: "Street", type: "text" },{ key: "city", label: "City", type: "text" },{ key: "country", label: "Country", type: "select", options: COUNTRIES },{ key: "newsletter", label: "Newsletter", type: "toggle", defaultValue: true },],// Each step references field KEYS from fields[] above --// strings only, not inline definitions. The renderer looks// each one up in fields[] to get its type + validation.steps: [{title: "Basics",description: "The essentials.",fields: ["name", "email"],},{title: "Address",fields: ["street", "city", "country"],},{title: "Preferences",fields: ["newsletter"],},],}
Each step renders with a progress bar at the top. The generator currently emits a single fields array — you opt into multi-step by adding the steps[] array by hand and pairing it with formView: "modal-steps" (or "page-steps") on the resource.
steps[].fields[] — e.g. fields: [{ key: "name", type: "text", ... }] — produces a Type ... is not assignable to type 'string' TypeScript error. The fix is to define the field once in the outer form.fields[] array and reference it by key (fields: ["name"]) here.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.
Sync auto-adds new fields (v3.31.16+)
Starting in v3.31.16, grit sync reaches into your admin resource file and appends any model fields that aren't represented yet. The magic happens between marker comments the generator now emits:
columns: [// grit:cols:auto-start{ key: "name", label: "Name", sortable: true, searchable: true },{ key: "email", label: "Email", sortable: true, searchable: true },// grit:cols:auto-end],form: {fields: [// grit:fields:auto-start{ key: "name", label: "Name", type: "text", required: true },{ key: "email", label: "Email", type: "text", required: true },// grit:fields:auto-end],},
Add a salutation field to your Go model, run grit migrate, then grit sync. The new field appears in both the table columns and the form fields with a sensible default type. Your customised entries — labels, helper text, badges, custom cells — are never touched.
grit sync prints a warning for those — either regenerate the resource file (loses customisation) or hand-add the four marker lines once. After that, sync will pick the file up automatically.Sync only adds; it never removes. If you delete a field from the Go model, the admin entry stays put — you decide whether to keep it as a derived field, move it elsewhere, or delete it by hand.
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
descriptionon 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