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.

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, 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:

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 17 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).
hiddennoSends 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.

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",
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
}
For really dynamic defaults (current user, route params, timestamps), drop out of the declarative form and use a custom page that wraps 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:

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.

Quick check

You added a new column `salutation` to your Go Contact model and ran `grit migrate` + `grit sync`. The admin form still doesn't show the field. What happened?

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 helperText 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