Admin Panel

Form Builder

The form builder generates create and edit forms from your resource definition. It supports a wide range of field types, Zod-based validation, single and two-column layouts, and seamless create/edit mode switching — all without writing any form JSX.

Form Modal and Full-Page Views

By default, create and edit forms open as a modal dialog that overlays the data table. This keeps the user in context — they can see the table behind the modal and quickly close it to return. The modal slides in from the right on desktop and opens as a full-screen sheet on mobile.

For resources with many fields or complex layouts, you can switch to afull-page form by adding formView: 'page' to your resource config. This renders the form as a dedicated page at /resources/[slug]/create or /resources/[slug]/[id]/edit.

Form view modes
// Modal (default) — opens over the data table
export default defineResource({
  name: 'Post',
  // formView: 'modal'  (this is the default, no need to specify)
  ...
})

// Full-page — navigates to a dedicated form page
export default defineResource({
  name: 'Invoice',
  formView: 'page',
  ...
})

Field Types

Each field in the form.fields array renders a specific input component. Below is a detailed reference for every supported field type.

Text Input

A standard single-line text input. Supports placeholder andrequired properties.

Text field
{
  key: 'title',
  label: 'Title',
  type: 'text',
  required: true,
  placeholder: 'Enter post title',
}

Textarea

A multi-line text area for longer content. Use the rows property to control the initial height (default: 4 rows). The textarea auto-resizes vertically as the user types if the content exceeds the visible area.

Textarea field
{
  key: 'description',
  label: 'Description',
  type: 'textarea',
  rows: 6,
  placeholder: 'Describe the product...',
}

Number

A numeric input with optional min, max, and step constraints. The input only accepts numeric values and shows increment/decrement arrows on hover.

Number field
{
  key: 'price',
  label: 'Price',
  type: 'number',
  min: 0,
  max: 99999,
  step: 0.01,
  placeholder: '0.00',
}

Select

A dropdown select menu. The options property accepts either an array of strings (used as both value and label) or an array of objects with explicit label and value properties.

Select field
// Simple string options
{
  key: 'status',
  label: 'Status',
  type: 'select',
  options: ['draft', 'published', 'archived'],
  default: 'draft',
}

// Object options with custom labels
{
  key: 'priority',
  label: 'Priority',
  type: 'select',
  options: [
    { label: 'Low',      value: 'low' },
    { label: 'Medium',   value: 'medium' },
    { label: 'High',     value: 'high' },
    { label: 'Critical', value: 'critical' },
  ],
}

Date Picker

A date input that opens a calendar popover. The selected date is serialized as an ISO 8601 string (2026-01-15T00:00:00.000Z) when submitted to the API. The calendar supports month/year navigation and respects the user's locale for day names.

Date field
{
  key: 'due_date',
  label: 'Due Date',
  type: 'date',
  required: true,
}

Toggle / Switch

A boolean toggle switch for on/off values. Renders as a sliding switch component. The value is submitted as true or false.

Toggle field
{
  key: 'featured',
  label: 'Featured Post',
  type: 'toggle',
  default: false,
}

Checkbox

A standard checkbox for boolean values. Visually different from a toggle — it renders as a small square with a checkmark. Typically used for consent, terms, or opt-in fields.

Checkbox field
{
  key: 'active',
  label: 'Active',
  type: 'checkbox',
  default: true,
}

Radio Group

A group of radio buttons for single-selection from multiple options. Radio groups are useful when you want all options visible at once (unlike a select dropdown that requires clicking to see options).

Radio field
{
  key: 'visibility',
  label: 'Visibility',
  type: 'radio',
  options: [
    { label: 'Public',   value: 'public' },
    { label: 'Private',  value: 'private' },
    { label: 'Unlisted', value: 'unlisted' },
  ],
  default: 'public',
}

Image Upload

A single image upload field powered by the Dropzone component. The file is uploaded to /api/uploads automatically and the form stores the resulting URL string. Accepts image/* MIME types.

Image field
{
  key: 'avatar',
  label: 'Avatar',
  type: 'image',
}

Multiple Images

An image gallery upload that stores an array of URL strings. Uses the Dropzone with multiple file support. Use the max property to limit the number of images (default: 10).

Images field
{
  key: 'gallery',
  label: 'Product Gallery',
  type: 'images',
  max: 8,
}

Video Upload

A single video upload field. Accepts video/mp4, video/webm, and video/quicktime formats. Max file size is 100MB by default.

Video field
{
  key: 'intro_video',
  label: 'Intro Video',
  type: 'video',
}

Multiple Videos

A multi-video upload that stores an array of URL strings. Use the max property to limit the number of videos (default: 5).

Videos field
{
  key: 'media',
  label: 'Course Videos',
  type: 'videos',
  max: 10,
}

File Upload

A single file upload for documents like PDFs, CSVs, Word files, etc. No MIME type restriction — accepts all allowed file types configured on the server.

File field
{
  key: 'resume',
  label: 'Resume (PDF)',
  type: 'file',
}

Multiple Files

A multi-file upload for document collections. Stores an array of URL strings. Use the max property to limit the number of files (default: 10).

Files field
{
  key: 'attachments',
  label: 'Attachments',
  type: 'files',
  max: 5,
}

Upload Variants

All upload field types (image, images, file,files, video, videos) use the Dropzone component under the hood. By default, uploads render as a large dashed drop zone, but you can customize the appearance with the variant property. Five variants are available:

default

The standard large dashed drop zone with drag-and-drop support. Uploaded files appear as grid preview thumbnails below the zone. Best suited for multi-file uploads where you want a prominent upload area.

default variant (this is the default, no need to specify)
{
  key: 'gallery',
  label: 'Product Gallery',
  type: 'images',
  max: 8,
  // variant: 'default'  (implied)
}

compact

An inline horizontal layout that takes less vertical space than the default. Good for single-file uploads where you want a smaller footprint. The file name and a remove button appear inline after upload.

compact variant
{
  key: 'document',
  label: 'Document',
  type: 'file',
  variant: 'compact',
}

minimal

A button-like minimal appearance that looks like a standard form control. Ideal when the upload area should blend in with other fields and not dominate the form visually.

minimal variant
{
  key: 'receipt',
  label: 'Receipt',
  type: 'file',
  variant: 'minimal',
}

avatar

A circular image preview with a hover overlay for changing the image. Designed specifically for profile pictures and user avatars. Shows a round preview of the current image with a camera icon overlay on hover.

avatar variant
{
  key: 'avatar',
  label: 'Profile Picture',
  type: 'image',
  variant: 'avatar',
}

inline

A horizontal layout with a "Browse" button and file name display on the same line. Similar to a native file input but styled to match the admin theme. Works well in compact form layouts.

inline variant
{
  key: 'thumbnail',
  label: 'Thumbnail',
  type: 'image',
  variant: 'inline',
}

Here is a complete example using multiple upload variants in a single resource definition:

Mixing upload variants in a form
form: {
  layout: 'two-column',
  fields: [
    { key: 'name', label: 'Name', type: 'text',
      required: true, span: 'full' },

    // Circular avatar preview for the profile picture
    { key: 'avatar', label: 'Avatar', type: 'image',
      variant: 'avatar', span: 'half' },

    // Compact single-file upload for a cover image
    { key: 'cover', label: 'Cover Image', type: 'image',
      variant: 'compact', span: 'half' },

    // Default large drop zone for a gallery
    { key: 'gallery', label: 'Gallery', type: 'images',
      max: 12, span: 'full' },

    // Inline file upload for a document
    { key: 'resume', label: 'Resume', type: 'file',
      variant: 'inline', span: 'half' },
  ],
}

Rich Text Editor

The richtext field type provides a full-featured rich text editor powered by Tiptap. It renders a toolbar with formatting controls and a content-editable area that stores its output as an HTML string.

Toolbar Actions

The editor toolbar includes the following formatting options:

  • Bold, Italic, Strikethrough — inline text formatting.
  • Heading 1, Heading 2, Heading 3 — block-level headings.
  • Bullet List, Ordered List — list structures.
  • Blockquote — indented quote blocks.
  • Code Block — syntax-highlighted code fences.
  • Link — insert or edit hyperlinks with a URL prompt.
  • Undo / Redo — history navigation.

Usage in Resource Definitions

Add a richtext field to your form definition. The field stores its content as an HTML string (e.g., <p>Hello <strong>world</strong></p>). In two-column layouts, rich text fields typically use span: 'full' to give the editor enough horizontal space.

Rich text field in a resource definition
form: {
  layout: 'two-column',
  fields: [
    { key: 'title', label: 'Title', type: 'text',
      required: true, span: 'full' },
    { key: 'status', label: 'Status', type: 'select',
      options: ['draft', 'published'], span: 'half' },
    { key: 'category_id', label: 'Category',
      type: 'relationship-select',
      relatedEndpoint: '/api/categories',
      displayField: 'name', span: 'half' },

    // Rich text editor — full width for maximum editing space
    { key: 'content', label: 'Content', type: 'richtext',
      span: 'full' },
  ],
}

Code Generator Support

The grit generate resource command supports the richtext type in YAML field definitions and in the CLI field syntax. The generator creates a text column in the Go model (to store the HTML) and arichtext form field in the admin resource definition.

Generating a resource with a richtext field
# CLI field syntax
grit generate resource Article title:string content:richtext status:select

# The generator produces:
# - Go model:  Content string `gorm:"type:text" json:"content"`
# - Zod schema: content: z.string().optional()
# - Form field: { key: 'content', label: 'Content', type: 'richtext' }

DataTable Display

When a richtext field appears in the DataTable, the HTML content is automatically stripped of tags and truncated to show a plain-text preview. This keeps the table rows compact while still giving a readable summary of the content.

DataTable column for richtext fields
// In the resource column definition:
{
  key: 'content',
  label: 'Content',
  format: 'richtext',   // strips HTML, truncates to ~80 chars
  sortable: false,
}

Relationship Fields

Two relationship field types let you associate resources. These fields auto-fetch options from a related API endpoint using React Query.

Relationship Select (belongs_to)

A searchable dropdown that loads all records from a related endpoint. Stores the selected item's ID as the field value.

Relationship select field
{
  key: 'category_id',
  label: 'Category',
  type: 'relationship-select',
  required: true,
  relatedEndpoint: '/api/categories',
  displayField: 'name',
}

Properties:

  • relatedEndpoint — the API endpoint to fetch options from (e.g., /api/categories).
  • displayField — which field from the related model to display in the dropdown (default: "name").

Multi Relationship Select (many_to_many)

A multi-select with toggle checkboxes and removable badge chips. Stores an array of IDs. In edit mode, existing selections are extracted from the API response using the relationshipKey property.

Multi relationship select field
{
  key: 'tag_ids',
  label: 'Tags',
  type: 'multi-relationship-select',
  relatedEndpoint: '/api/tags',
  displayField: 'name',
  relationshipKey: 'tags',
}

The relationshipKey tells the form where to find the existing related objects in the API response (e.g., product.tags). In edit mode, the form extracts tag.id from each object to pre-select them.

Validation

Form validation is powered by Zod schemas from the shared package (packages/shared/schemas/). When you rungrit generate resource, a Zod schema is generated alongside the resource. The form builder uses this schema for client-side validation.

Client-Side Validation

Validation runs on two events:

  • On blur — when the user leaves a field, that field is validated immediately.
  • On submit — all fields are validated before the form is submitted to the API.

Error messages appear below each field in red text. The first invalid field is scrolled into view and focused automatically.

packages/shared/schemas/post.ts
import { z } from 'zod'

export const CreatePostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  content: z.string().optional(),
  status: z.enum(['draft', 'published', 'archived']).default('draft'),
  category: z.string().min(1, 'Category is required'),
  featured: z.boolean().default(false),
})

export const UpdatePostSchema = CreatePostSchema.partial()

export type CreatePostInput = z.infer<typeof CreatePostSchema>
export type UpdatePostInput = z.infer<typeof UpdatePostSchema>

Server-Side Error Display

When the Go API returns a 422 Validation Error response, the form builder parses the error details and maps them to individual fields. Server-side errors appear below the relevant field, just like client-side errors. This handles cases that cannot be validated on the client, such as unique constraint violations.

API 422 response
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "email": "This email is already registered",
      "slug": "This slug is already taken"
    }
  }
}

Form Layout

Two layout modes are available for forms:

Single Column (Default)

All fields stack vertically in a single column. This is the default and works well for forms with 5 or fewer fields.

Two-Column Layout

Fields are arranged in a two-column grid. Use the span property on individual fields to control whether they take half or full width:

  • span: 'half' — field takes one column (default in two-column mode).
  • span: 'full' — field spans both columns.
Two-column layout example
form: {
  layout: 'two-column',
  fields: [
    // Full width — spans both columns
    { key: 'title', label: 'Title', type: 'text',
      required: true, span: 'full' },

    // Half width — each takes one column, side by side
    { key: 'category', label: 'Category', type: 'select',
      options: ['tech', 'design', 'business'] },
    { key: 'status', label: 'Status', type: 'select',
      options: ['draft', 'published'] },

    // Full width again
    { key: 'content', label: 'Content', type: 'richtext',
      span: 'full' },

    // Two half-width fields on the same row
    { key: 'published_at', label: 'Publish Date', type: 'date' },
    { key: 'featured', label: 'Featured', type: 'toggle' },
  ],
}

Create vs Edit Modes

The same form definition powers both create and edit workflows. The form builder automatically detects the mode based on whether an existing record is passed:

  • Create mode — form fields start empty (or with default values). The submit button says "Create [Resource]". On submit, a POST request is sent to the API endpoint.
  • Edit mode — form fields are pre-populated with the existing record data. The submit button says "Update [Resource]". On submit, a PUT request is sent to [endpoint]/[id].

In edit mode, only changed fields are included in the request body (partial updates). This is handled automatically by comparing the initial values with the submitted values.

Default Values

Use the default property on any field to set an initial value in create mode. Default values are ignored in edit mode where the existing record data takes precedence.

Default values
fields: [
  { key: 'status', label: 'Status', type: 'select',
    options: ['draft', 'published'], default: 'draft' },
  { key: 'priority', label: 'Priority', type: 'number',
    default: 1, min: 1, max: 5 },
  { key: 'active', label: 'Active', type: 'toggle',
    default: true },
  { key: 'visibility', label: 'Visibility', type: 'radio',
    options: ['public', 'private'], default: 'public' },
]

Complete Form Example

Here is a full form configuration for an Invoice resource that demonstrates multiple field types, two-column layout, validation, and default values:

apps/admin/resources/invoices.ts (form section)
form: {
  layout: 'two-column',
  validation: 'InvoiceSchema',   // References packages/shared/schemas
  fields: [
    { key: 'number', label: 'Invoice Number', type: 'text',
      required: true, placeholder: 'INV-001', span: 'half' },
    { key: 'customer_id', label: 'Customer', type: 'relation',
      resource: 'customers', displayKey: 'name', span: 'half' },

    { key: 'amount', label: 'Amount ($)', type: 'number',
      required: true, min: 0, step: 0.01, span: 'half' },
    { key: 'status', label: 'Status', type: 'select',
      options: [
        { label: 'Pending', value: 'pending' },
        { label: 'Paid',    value: 'paid' },
        { label: 'Overdue', value: 'overdue' },
      ], default: 'pending', span: 'half' },

    { key: 'due_date', label: 'Due Date', type: 'date',
      required: true, span: 'half' },
    { key: 'issued_at', label: 'Issue Date', type: 'date',
      span: 'half' },

    { key: 'notes', label: 'Notes', type: 'textarea',
      rows: 4, placeholder: 'Internal notes...', span: 'full' },

    { key: 'send_notification', label: 'Send email notification',
      type: 'checkbox', default: true, span: 'full' },

    { key: 'attachments', label: 'Attachments', type: 'file',
      multiple: true, span: 'full' },
  ],
}

Auto-Generated Fields (Slug)

Fields with the slug type are automatically excluded from forms. Instead of being editable, slugs are auto-generated from another field (usually the name) when a record is created. They still appear in the DataTable as a read-only column.

Generating a resource with a slug field
# Slug auto-detects source (first string field = "name")
grit generate resource Category name:string slug:slug description:text

# Explicit source field
grit generate resource Article title:string slug:slug:title content:text

The code generator handles everything automatically:

Generated Go model with BeforeCreate hook
type Category struct {
    ID          uint           `gorm:"primarykey" json:"id"`
    Name        string         `gorm:"size:255" json:"name"`
    Slug        string         `gorm:"size:255;uniqueIndex" json:"slug"`
    Description string         `gorm:"type:text" json:"description"`
    CreatedAt   time.Time      `json:"created_at"`
    ...
}

// BeforeCreate auto-generates the slug before inserting.
func (m *Category) BeforeCreate(tx *gorm.DB) error {
    if m.Slug == "" {
        m.Slug = slugify(m.Name) // "Electronics" → "electronics-a8f3x2k9"
    }
    return nil
}

The slug includes an 8-character unique suffix to prevent collisions (e.g., electronics-a8f3x2k9). The Zod create/update schemas also exclude slug fields, since they're never sent by the client.