Customising admin forms

The 17 field types, helper text, multi-step flows, when to drop out of the declarative form.

12 minmedium

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:

apps/admin/app/(dashboard)/resources/contacts/page.tsx
"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:

formViewRenders asBest for
"sheet" (default)Right drawer on desktop, bottom sheet on mobileLong forms, lots of fields, multi-line textareas
"modal"Centered dialog over a backdropShort forms (1-6 fields), focused single-task flows
"page"Dedicated route, full pageVery long forms, anything that needs URL state or shareable links
"modal-steps"Sheet with step navigationWizard inside a sheet
"page-steps"Full-page wizardMulti-step flows you want to bookmark mid-way
apps/admin/resources/contacts.ts (excerpt)
export const contactResource = defineResource({
name: "Contact",
slug: "contacts",
// ...
formView: "modal", // ← centered dialog (was "sheet" by default)
table: { /* ... */ },
form: { /* ... */ },
});
Migrating from v3.31.16: the old "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

apps/admin/resources/contacts.ts (excerpt)
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:

KeyRequired?What it does
keyyesJSON key sent to the API. Must match the Go struct's json tag.
labelyesHuman-friendly label shown above the input.
typeyesOne of the 18 field types listed below.
requirednoShows a red star, blocks submit when empty.
placeholdernoGrey hint text inside the input.
helperTextnoSmall note rendered below the input. Use for hints.
defaultValuenoPre-fills the field when opening Create (not Edit).
colSpanno1 or 2 — number of columns the field spans in a two-column layout.
prefix / suffixnoInline 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.

TypeWidgetUse it for
textsingle-line inputname, email, phone, slug, short fields
textareamulti-line inputnotes, plain descriptions
richtextTiptap Word-style editorblog body, formatted content
numbernumeric inputstock counts, ratings, percentages
selectdropdownfixed enum (status, priority, role)
radioradio groupshort enum visible at a glance
checkboxcheckboxterms acceptance
toggleswitchis_active, published, featured
datedate pickerbirthday, deadline
datetimedatetime pickerscheduled_at, published_at
image / video / filesingle uploadavatar, cover photo, hero video
images / videos / filesmulti uploadgallery, attachments
relationship-selectasync dropdownbelongs_to (group, customer)
multi-relationship-selectasync multi-dropdownmany_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:

apps/admin/resources/contacts.ts (excerpt)
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" },
],
}
Need a server-derived default like "current user" or a route param? The declarative form only supports static defaults. For dynamic ones, set the column on the API side — give the Go model a 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.

apps/admin/resources/products.ts
export const productResource = defineResource({
// ...
formView: "page", // groups only render on page-view edits
form: {
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 standard POST.
  • 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.
Why per-group PATCH? Saving "Marketing" while another user is editing "Inventory" doesn't clobber their in-flight changes. The Go-side 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.

apps/admin/resources/contacts.ts
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.

Common gotcha: putting full field objects inside 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:

apps/admin/app/(dashboard)/resources/contacts/page.tsx (custom)
"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:

apps/admin/resources/contacts.ts
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.

Resources scaffolded before v3.31.16 don't have the marker comments. 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

You added a `salutation` field to Contact in Go (post-v3.31.16) and ran `grit migrate` + `grit sync`. What appears in the admin?

Try it

In your contact-app, customise the Contact form three ways in one sitting:

  1. Change the phone placeholder to +1 555 123 4567.
  2. Add a description on email saying "We'll send a verification link."
  3. Add a new status dropdown with options active, inactive, archived (default active). You'll also need to add the column to the Go model and run grit migrate + grit sync first.

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