For AI Assistants

Grit Framework — Complete LLM Reference

The canonical guide for AI assistants building with Grit. Covers every CLI command, all field types with real examples, resource generation patterns, form builder, datatable builder, standalone usage, relationships, slugs, media fields, and the rules that must never be broken.

For AI tools: Read this entire page before generating any code. Every convention here is enforced by the CLI — deviating breaks grit generate and grit sync.

Prefer a PDF?

Download the Grit Handbook for offline reading and printing

1What is Grit?

Grit is a full-stack meta-framework that fuses a Go backend with Next.js frontends inside a Turborepo monorepo. It is opinionated, batteries-included, and optimised for AI-assisted development. A single CLI command scaffolds a complete production-ready project.

Backend

Go 1.24 · Gin · GORM · PostgreSQL · Redis

Frontend

Next.js 15 · React 19 · TypeScript · Tailwind CSS

Admin panel

Custom Filament-like panel scaffolded with Next.js

Shared types

packages/shared — Zod schemas + TypeScript types

Monorepo

Turborepo + pnpm workspaces

Infra

Docker Compose · MinIO · Mailhog (dev) · S3/R2/Resend (prod)

2Project Structure

Grit monorepo layout
myapp/
├── apps/
│ ├── api/ # Go backend
│ │ └── internal/
│ │ ├── config/ # DB, Redis, env, storage
│ │ ├── middleware/ # Gzip, CORS, RequestID, Logger, Auth, Cache
│ │ ├── models/ # GORM structs + model registry
│ │ │ └── user.go # // grit:models marker — NEVER remove
│ │ ├── handlers/ # Thin HTTP handlers (call services)
│ │ ├── services/ # Business logic + GORM queries
│ │ ├── routes/ # Route registration
│ │ │ └── routes.go # // grit:routes:* markers — NEVER remove
│ │ ├── jobs/ # asynq background jobs
│ │ └── database/ # seed.go (// grit:seeders) + <plural>_seeder.go
│ ├── web/ # Next.js public website
│ │ ├── app/ # App Router pages
│ │ ├── components/
│ │ └── lib/api-client.ts # Axios client + uploadFile()
│ └── admin/ # Next.js admin panel
│ ├── app/(dashboard)/ # Protected admin pages
│ │ └── [resource]/
│ │ ├── page.tsx # ResourcePage component
│ │ └── _resource.ts # defineResource() config
│ ├── components/ # DataTable, FormBuilder, FormStepper
│ ├── hooks/ # React Query hooks (generated)
│ └── lib/api-client.ts # Axios client + uploadFile()
├── packages/
│ └── shared/
│ └── src/
│ ├── schemas/ # Zod schemas (generated by grit sync)
│ ├── types/ # TypeScript interfaces
│ └── index.ts # Re-exports everything
├── docker-compose.yml # PostgreSQL, Redis, MinIO, Mailhog
├── docker-compose.prod.yml # Production config
├── turbo.json
├── pnpm-workspace.yaml
├── grit.config.ts # Project config
├── GRIT_SKILL.md # AI context file (auto-generated)
└── .env # Environment variables

3All CLI Commands

Install once with the one-line script (curl -fsSL https://gritframework.dev/install.sh | sh on macOS / Linux, iwr -useb https://gritframework.dev/install.ps1 | iex on Windows) — or with Go directly via go install github.com/MUKE-coder/grit/v3/cmd/grit@latest. Every command is idempotent — safe to re-run.

Project Lifecycle

grit new <app-name>

Scaffold a complete new project. Interactive by default — prompts for architecture mode and frontend. 5 architecture modes: --single (one Go binary with embedded SPA), --double (API + web), --triple (API + web + admin), --api (headless API), --mobile (API + Expo). 2 frontends: --next (Next.js), --vite (TanStack Router). Plus a --desktop flag that adds a Wails desktop client to any monorepo architecture (combinable with --triple, --double, --mobile, --api; NOT --single). Supports grit new . to scaffold into current directory, --here for explicit in-place scaffolding, and --force to scaffold into a non-empty directory. Other flags: --full (default, all apps), --style default|modern|minimal|glass|centered. The --desktop flag is distinct from grit new-desktop (standalone offline-first app with embedded Go + SQLite); --desktop is for always-online multi-client apps that share the monorepo API.

$ grit new myapp grit new myapp --triple --vite grit new . --triple --vite grit new myapp --triple --next --desktop grit new myapp --mobile --desktop
grit start server

Start ONLY the Go API server (no hot-reload). Runs go run from apps/api. Use this when you only need the backend, or for production-like API testing.

$ grit start server
grit start client

Start ONLY the frontend apps (web + admin) via pnpm dev / Turborepo. Does not start the Go API. Use this when the backend is already running separately.

$ grit start client

There is no grit dev command — it does not exist. Run the API and frontend in separate terminals: grit start server in Terminal 1, then grit start client in Terminal 2. Or run grit start to launch everything at once.

Code Generation

grit generate resource <Name> --fields "field:type,..."

Generate a full-stack resource: Go model + GORM migration, handler, service, routes, Zod schema, TypeScript types, React Query hook, and admin page — all wired together. Fields are comma-separated name:type[:extra] triples. Special types: slug:slug:title (auto-generated from the title field), file/files with an accept-list (image:file:image, gallery:files:image, doc:file:all), category:belongs_to (one-to-many parent, model inferred), tags:many_to_many:Tag (join table), sizes:string_array (JSON array). Extra flags: --seed (also emit a seeder), --faker + --count N (seed many realistic rows), --from schema.yaml (fields from YAML), -i / --interactive (prompt per field), --roles "ADMIN,EDITOR" (restrict to roles).

$ grit generate resource Product --fields "name:string,slug:slug:name,price:float,image:file:image"
grit generate seeder <Resource> [<Resource2> ...]

Add a seeder to one or more already-generated resources. Each seeder is written to internal/database/<plural>_seeder.go and registered at the // grit:seeders marker in seed.go. Pass --faker to fill many rows with realistic gofakeit data (--count N sets the row count, default 10). Faker links belongs_to fields to REAL existing parent rows, so seed parents first.

$ grit generate seeder Product grit generate seeder Category Product --faker --count 50
grit sync

Regenerate packages/shared/src/schemas and packages/shared/src/types from all Go models. Run this after manually editing a Go model struct.

$ grit sync
grit remove resource <Name>

Remove a previously generated resource: deletes Go files, removes the model from the registry, cleans up routes, and removes the admin page.

$ grit remove resource Product

Database & Roles

grit migrate

Run GORM AutoMigrate for all models in RegisteredModels. Safe to run repeatedly — adds new columns/tables without dropping existing data. Use --fresh to drop all tables first and start clean (WARNING: destroys all data — development only).

$ grit migrate # safe incremental grit migrate --fresh # drop + recreate all tables
grit seed

Run every seeder in apps/api/internal/database/ (users_seeder.go, blogs_seeder.go, and any <plural>_seeder.go you generate). Creates the default admin user and sample data. Seeders are idempotent — they skip a table that already has rows, so grit seed is safe to re-run.

$ grit seed
grit add role <ROLE_NAME>

Add a new RBAC role in 7 places simultaneously: Go constants, Go middleware, Zod schema, TypeScript type, admin dropdown, seed file, and GRIT_SKILL.md.

$ grit add role MODERATOR

Utilities

grit upgrade

Regenerates framework scaffold files (admin panel, web app, shared package configs) in the current project while preserving your custom resource code. Run this after a new Grit release to pull in updated templates. Use --force to skip confirmation prompts. This upgrades PROJECT FILES — not the CLI binary.

$ grit upgrade grit upgrade --force
grit update

Removes the current Grit CLI binary from $GOPATH/bin and reinstalls the latest version from GitHub via go install. Use this to update the grit TOOL ITSELF — not the project files (use grit upgrade for that).

$ grit update
grit routes

List all registered API routes in the project. Useful for debugging and verifying that generated routes are wired correctly.

$ grit routes
grit down

Enable maintenance mode. Returns 503 Service Unavailable for all requests. Use this during deployments or database migrations.

$ grit down
grit up

Disable maintenance mode. Resumes normal request handling after grit down.

$ grit up
grit deploy

Deploy the project to a production server. Handles build, push, and server configuration.

$ grit deploy
grit version

Print the installed CLI version.

$ grit version

4Field Types for Code Generation

Fields follow the format name:type with an optional third part: name:type:extra (a modifier like unique, a slug source field, a related model, or a file accept-list). There are exactly 15 valid types — anything else fails validation. String fields default to required; every other type defaults to optional.

TypeGo TypeAdmin Form FieldNotes
stringstringtext inputShort text, titles, names. Defaults to required.
textstringtextareaLong text, descriptions
richtextstringrich text editorHTML content (TipTap)
intintnumber inputSigned integer
uintuintnumber inputUnsigned integer (counts, quantities)
floatfloat64number input (decimal)Prices, coordinates. Use float, never float64.
boolbooltoggle / checkboxActive, published, featured
datetime*time.Timedate-time pickerTimestamps (published_at). Use datetime, never time.
date*time.Timedate pickerCalendar date only (due dates)
slugstring (uniqueIndex)text input (auto-generated)slug:slug:title — 3rd part is the source field. Auto-unique.
belongs_tostring FK + Model fieldrelationship selectcategory:belongs_to or author:belongs_to:User. FK column <name>_id. Required by default.
many_to_many[]Model (GORM M2M)multi-relationship selecttags:many_to_many:Tag — related model required as 3rd part.
string_arraydatatypes.JSONSlice[string]tag / multi-text inputsizes:string_array — JSON array of strings
file*files.FileReffile upload (dropzone)image:file:image · doc:file:all · att:file:[pdf,doc,image]
filesfiles.FileRefsmulti-file dropzonegallery:files:image — multiple files with an accept-list

Understanding Slugs

A slug is a URL-friendly string derived from a title. Instead of /blog/42, you get /blog/my-first-post. In Grit, use slug:slug:title (not slug:string) — the third part names the source field to slugify. The model's BeforeCreate hook auto-generates the slug before saving, and GORM adds a uniqueIndex automatically.

apps/api/internal/models/post.go (generated)
type Post struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
Title string `gorm:"size:255;not null" json:"title"`
Slug string `gorm:"size:255;uniqueIndex" json:"slug"`
Content string `gorm:"type:text" json:"content"`
Published bool `gorm:"default:false" json:"published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate assigns the UUID primary key and auto-generates the slug:
func (m *Post) BeforeCreate(tx *gorm.DB) error {
if m.ID == "" {
m.ID = uuid.NewString()
}
if m.Slug == "" {
m.Slug = slug.Make(m.Title)
}
return nil
}
// The public GetBySlug handler route: GET /api/posts/slug/:slug

Always use slug:slug:title (not slug:string) — the dedicated type adds a uniqueIndex and wires up auto-generation from the source field automatically.

Understanding Relationships

TypeSyntaxAdmin Field
One-to-Many (belongs_to)name:belongs_to or name:belongs_to:ModelSearchable single select
One-to-Onename:belongs_to:Model (add uniqueIndex manually)Searchable single select
Many-to-Manyname:many_to_many:ModelMulti-select tag input

One-to-Many (belongs_to)

A child record belongs to one parent. Syntax: name:belongs_to (parent inferred from the field name) or name:belongs_to:Model to name it explicitly. This adds a CategoryID string FK column (varchar(36), matching the parent's UUID) and a Category Category preload field to the struct. belongs_to is required by default. The admin form renders a searchable single-select dropdown populated from /api/categories.

Example — Post belongs to Category
# 1. Generate the parent first
grit generate resource Category --fields "name:string,slug:slug:name,description:text"
# 2. Generate the child referencing it (category → Category)
grit generate resource Post \
--fields "title:string,slug:slug:title,content:richtext,category:belongs_to,is_published:bool"
grit migrate

One-to-One (has_one)

Exactly one child per parent. Uses the same belongs_to syntax name:belongs_to:Model — which produces a UserID string FK column. To enforce one-to-one, add a uniqueIndex to that FK column in the generated Go model, then run grit migrate. The admin form also renders a single-select dropdown (same as belongs_to).

Example — Profile has one User
# 1. User model already exists (built in)
# 2. Generate Profile with a belongs_to to User
grit generate resource Profile \
--fields "bio:text,avatar:file:image,website:string,user:belongs_to:User"
# For strict one-to-one, add uniqueIndex to the UserID gorm tag in
# apps/api/internal/models/profile.go, then run grit migrate
grit migrate

Many-to-Many

A record can belong to many of another, and vice versa. Syntax: name:many_to_many:Model. The related model is required as the third part (e.g. tags:many_to_many:Tag) — Grit does not infer it. GORM creates a join table automatically (e.g. product_tags). The admin form renders a multi-select tag input populated from /api/tags.

Example — Product has many Tags
# 1. Generate the related model first
grit generate resource Tag --fields "name:string,slug:slug:name,color:string"
# 2. Generate Product with an M2M relationship
grit generate resource Product \
--fields "name:string,slug:slug:name,price:float,thumbnail:file:image,tags:many_to_many:Tag,is_active:bool"
# GORM auto-creates the product_tags join table
grit migrate

5Resource Generation — Real Examples

These are the patterns you will generate most often. Copy and adapt them. Always run grit migrate after generating.

Simple Model — No Media, No Relations

A contact form submission. There is no enum type — model a status as a plain string and validate the allowed values (new, read, replied) in app code.

grit generate resource Contact \
--fields "name:string,email:string,subject:string,message:text,status:string,is_spam:bool"
grit migrate

Blog Post — Slug + Single Image + Rich Text

Use slug:slug:title for SEO-friendly URLs (the third part is the source field). A single image is a file:image field, and a timestamp is datetime.

grit generate resource Post \
--fields "title:string,slug:slug:title,excerpt:text,content:richtext,cover_image:file:image,status:string,published_at:datetime,views:int"
grit migrate

Product — Gallery + Single Image + Slug

A gallery is a files:image field (multiple files, stored as JSON), and a single hero image is file:image. Prices are float, and the status is a plain string.

grit generate resource Product \
--fields "name:string,slug:slug:name,description:text,price:float,compare_price:float,sku:string,stock:int,thumbnail:file:image,gallery:files:image,is_featured:bool,is_active:bool,status:string"
grit migrate

Relationships — belongs_to + many_to_many

Generate Category first (parent), then Product referencing it. Tags are many-to-many.

# Step 1: generate parent models first
grit generate resource Category --fields "name:string,slug:slug:name,description:text,image:file:image"
grit generate resource Tag --fields "name:string,slug:slug:name,color:string"
# Step 2: generate the child with belongs_to and many_to_many
grit generate resource Article \
--fields "title:string,slug:slug:title,content:richtext,cover_image:file:image,category:belongs_to,tags:many_to_many:Tag,author:belongs_to:User,is_published:bool,published_at:datetime"
grit migrate

This creates: category_id FK column (varchar(36)), article_tags join table, and author_id FK to users — all wired in GORM with preload support.

Course + Lessons — Video Uploads + belongs_to

Video is a file accept-alias: intro_video:file:video for a single video, lesson_videos:files:video for a set. These use presigned URL uploads directly to R2/S3 — the API never handles the binary.

# Course (parent)
grit generate resource Course \
--fields "title:string,slug:slug:title,description:text,thumbnail:file:image,intro_video:file:video,price:float,level:string,is_published:bool"
# Lesson (child — belongs to Course via course:belongs_to)
grit generate resource Lesson \
--fields "title:string,slug:slug:title,description:text,video:file:video,duration:int,position:int,is_preview:bool,course:belongs_to:Course,attachments:files:all"
grit migrate

E-Commerce Order — Full Complexity

Status-style columns are plain string fields (validate the allowed values in app code), money is float, timestamps are datetime, and the customer is belongs_to:User.

grit generate resource Order \
--fields "order_number:string,status:string,subtotal:float,tax:float,shipping_fee:float,total:float,notes:text,shipping_address:text,payment_method:string,payment_status:string,paid_at:datetime,shipped_at:datetime,delivered_at:datetime,customer:belongs_to:User"
grit migrate

6Code Patterns

Backend: Handler → Service → Model

Handlers are thin controllers. All DB logic lives in services.

apps/api/internal/handlers/product_handler.go
type ProductHandler struct {
Service *services.ProductService
Storage *config.Storage // only when resource has image/file/video fields
}
func (h *ProductHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
products, total, pages, err := h.Service.List(page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to fetch products"},
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": products,
"meta": gin.H{"total": total, "page": page, "page_size": pageSize, "pages": pages},
})
}
func (h *ProductHandler) Create(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Price float64 `json:"price" binding:"required"`
Image string `json:"image"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}
product, err := h.Service.Create(req.Name, req.Price, req.Image)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to create product"},
})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product, "message": "Product created successfully"})
}

Frontend: Component → Hook → API

apps/admin/app/(dashboard)/products/page.tsx
'use client'
import { useProducts, useCreateProduct } from '@/hooks/use-products'
import { ResourcePage } from '@/components/resource/resource-page'
import { productsResource } from './_resource'
export default function ProductsPage() {
return <ResourcePage resource={productsResource} />
}

7API Response Format

Never deviate from this format. Frontend hooks and admin components depend on this exact shape.

Success — single record
{ "data": { "id": 1, "name": "Widget", "price": 29.99 }, "message": "Product created successfully" }
Success — paginated list
{
"data": [{ "id": 1, "name": "Widget" }, { "id": 2, "name": "Gadget" }],
"meta": { "total": 42, "page": 1, "page_size": 20, "pages": 3 }
}
Error
{ "error": { "code": "VALIDATION_ERROR", "message": "name is required" } }
SituationHTTP StatusCode
Missing required field422VALIDATION_ERROR
Record not found404NOT_FOUND
Not authenticated401UNAUTHORIZED
Insufficient role403FORBIDDEN
DB / internal error500INTERNAL_ERROR
Duplicate / conflict409CONFLICT

8Code Markers — NEVER Delete

These comments are injection points for the CLI. Removing them permanently breaks grit generate and grit add role. Never remove, rename, or move them.

apps/api/internal/models/user.go
// Models returns every GORM model registered for AutoMigrate.
func Models() []interface{} {
return []interface{}{
&User{}, &Upload{}, &Blog{},
&Product{}, // grit generate adds new models above this marker
// grit:models
}
}
apps/api/internal/routes/routes.go
// Protected routes (require a valid JWT)
protected.GET("/products", productHandler.List)
protected.POST("/products", productHandler.Create)
// grit:routes:protected
// Admin-only routes (require the ADMIN role)
admin.DELETE("/products/:id", productHandler.Delete)
// grit:routes:admin
// Public / custom routes and role-restricted resource groups
// grit:routes:custom

9Form Builder — Detailed Guide

Every resource in Grit has a form driven by the form key in defineResource(). The same FormBuilder component works in modals, full pages, and multi-step wizards.

Form View Modes

formView: 'modal'

Modal (default)

Form slides in as a dialog over the data table. Best for simple resources.

formView: 'page'

Full Page

Navigates to a dedicated /resources/[slug]?action=create page. Best for long forms.

formView: 'modal-steps'

Modal + Steps

Multi-step wizard inside a modal. Best for complex resources with many fields.

formView: 'page-steps'

Full Page + Steps

Multi-step wizard as a full page. Best for onboarding flows.

Complete defineResource() Example

apps/admin/app/(dashboard)/products/_resource.ts
import { defineResource } from "@/lib/resource";
export const productsResource = defineResource({
name: "Product",
slug: "products",
endpoint: "/api/products",
icon: "Package",
label: { singular: "Product", plural: "Products" },
// ── Form config ───────────────────────────────────────────────
formView: "modal-steps", // modal | page | modal-steps | page-steps
form: {
layout: "two-column", // single | two-column
steps: [
{
title: "Basic Info",
description: "Name, slug and pricing",
fields: ["name", "slug", "price", "compare_price", "sku", "stock"],
},
{
title: "Details",
description: "Description and category",
fields: ["description", "category_id", "status", "is_featured"],
},
{
title: "Media",
description: "Upload product images",
fields: ["thumbnail", "gallery"],
},
],
fields: [
// Text fields
{ key: "name", label: "Product Name", type: "text", required: true },
{ key: "slug", label: "Slug", type: "text", placeholder: "auto-generated from name" },
{ key: "sku", label: "SKU", type: "text" },
// Number fields with prefix/suffix
{ key: "price", label: "Price", type: "number", required: true, min: 0, step: 0.01, prefix: "$" },
{ key: "compare_price", label: "Compare at", type: "number", min: 0, step: 0.01, prefix: "$" },
{ key: "stock", label: "Stock", type: "number", min: 0, defaultValue: 0 },
// Rich text
{ key: "description", label: "Description", type: "richtext", colSpan: 2 },
// Select / enum
{
key: "status", label: "Status", type: "select", required: true,
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Out of Stock", value: "out_of_stock" },
],
defaultValue: "draft",
},
// Boolean toggle
{ key: "is_featured", label: "Featured", type: "toggle", defaultValue: false },
// Relationship (belongs_to) — single select
{
key: "category_id", label: "Category", type: "relationship-select",
relatedEndpoint: "/api/categories",
displayField: "name",
required: true,
},
// Many-to-many — multi select
{
key: "tag_ids", label: "Tags", type: "multi-relationship-select",
relatedEndpoint: "/api/tags",
displayField: "name",
relationshipKey: "tag_relations",
colSpan: 2,
},
// Images
{ key: "thumbnail", label: "Thumbnail", type: "image", description: "Main product image" },
{ key: "gallery", label: "Gallery", type: "images", colSpan: 2, description: "Additional product images" },
],
},
// ── Table config ──────────────────────────────────────────────
table: {
columns: [
{ key: "thumbnail", label: "", format: "image", width: "56px" },
{ key: "name", label: "Product", sortable: true, searchable: true },
{ key: "sku", label: "SKU", sortable: true },
{ key: "price", label: "Price", sortable: true, format: "currency" },
{ key: "stock", label: "Stock", sortable: true, format: "number" },
{
key: "status", label: "Status", format: "badge",
badge: {
draft: { color: "muted", label: "Draft" },
published: { color: "success", label: "Published" },
out_of_stock: { color: "warning", label: "Out of Stock" },
},
},
{ key: "created_at", label: "Created", format: "relative", sortable: true },
],
filters: [
{
key: "status", label: "Status", type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Out of Stock", value: "out_of_stock" },
],
},
{ key: "is_featured", label: "Featured", type: "boolean" },
],
searchable: true,
searchPlaceholder: "Search products…",
actions: ["create", "view", "edit", "delete"],
bulkActions: ["delete"],
defaultSort: { key: "created_at", direction: "desc" },
pageSize: 20,
},
});

All Form Field Types

All supported field types in the form.fields array
// ── Text inputs ──────────────────────────────────────────────────
{ key: "title", type: "text", label: "Title", required: true, placeholder: "…" }
{ key: "bio", type: "textarea", label: "Bio", rows: 6 }
{ key: "content", type: "richtext", label: "Content", colSpan: 2 }
// ── Numbers ───────────────────────────────────────────────────────
{ key: "price", type: "number", label: "Price", min: 0, max: 999999, step: 0.01, prefix: "$" }
{ key: "weight", type: "number", label: "Weight", suffix: "kg" }
// ── Date & time ───────────────────────────────────────────────────
{ key: "start_date", type: "date", label: "Start Date" }
{ key: "event_time", type: "datetime", label: "Event Time" }
// ── Booleans ──────────────────────────────────────────────────────
{ key: "is_active", type: "toggle", label: "Active", defaultValue: true }
{ key: "agree", type: "checkbox", label: "I agree to the terms" }
// ── Select / enum ─────────────────────────────────────────────────
{
key: "role", type: "select", label: "Role",
options: [{ label: "Admin", value: "ADMIN" }, { label: "User", value: "USER" }],
defaultValue: "USER",
}
{ key: "gender", type: "radio", label: "Gender",
options: [{ label: "Male", value: "male" }, { label: "Female", value: "female" }] }
// ── Media uploads (presigned URL to S3/R2/MinIO) ──────────────────
{ key: "avatar", type: "image", label: "Avatar", description: "Profile picture" }
{ key: "gallery", type: "images", label: "Gallery", colSpan: 2 }
{ key: "intro_video", type: "video", label: "Intro Video" }
{ key: "lesson_videos", type: "videos", label: "Lesson Videos", colSpan: 2 }
{ key: "attachment", type: "file", label: "Attachment", accept: ".pdf,.docx", maxSize: 5242880 }
{ key: "documents", type: "files", label: "Documents", colSpan: 2 }
// ── Relationships ─────────────────────────────────────────────────
{
key: "category_id", type: "relationship-select", label: "Category",
relatedEndpoint: "/api/categories", displayField: "name",
}
{
key: "tag_ids", type: "multi-relationship-select", label: "Tags",
relatedEndpoint: "/api/tags", displayField: "name", relationshipKey: "tag_relations",
}

10DataTable Builder — Detailed Guide

The table key in defineResource() controls columns, filters, sorting, search, pagination, actions, and cell formatting.

Column Formats

formatRenders as
(none)Plain text
imageThumbnail <img> with rounded corners
badgeColored pill — needs badge: { VALUE: { color, label } }
booleanGreen check ✓ or red × icon
currencyFormatted number with currency symbol
numberNumber with locale thousand separators
relativeRelative time e.g. "3 days ago"
dateFormatted date string
datetimeFormatted date + time
linkClickable anchor to the value URL

Filter Types

table.filters examples
filters: [
// Select dropdown filter
{
key: "status", label: "Status", type: "select",
options: [{ label: "Active", value: "active" }, { label: "Inactive", value: "inactive" }],
},
// Boolean filter (yes/no toggle)
{ key: "is_featured", label: "Featured", type: "boolean" },
// Date range filter
{ key: "created_at", label: "Created", type: "date" },
// Text search filter
{ key: "email", label: "Email", type: "text" },
]

Actions & Bulk Actions

table actions
table: {
// Per-row action buttons in the actions column
actions: ["create", "view", "edit", "delete"], // all 4 available
// Checkbox multi-select + bulk action toolbar
bulkActions: ["delete"],
// Default sort
defaultSort: { key: "created_at", direction: "desc" },
// Rows per page (default 20)
pageSize: 20,
}

11Standalone Usage — Forms & Tables on Any Page

FormBuilder, DataTable, and FormStepper are independent components. Use them on any page in the web or admin app without going through the resource system.

Standalone FormBuilder

apps/admin/app/(dashboard)/settings/page.tsx
'use client'
import { FormBuilder } from '@/components/forms/form-builder'
import type { FormDefinition } from '@/lib/resource'
const settingsForm: FormDefinition = {
layout: 'two-column',
fields: [
{ key: 'site_name', label: 'Site Name', type: 'text', required: true, colSpan: 1 },
{ key: 'site_url', label: 'Site URL', type: 'text', required: true, colSpan: 1 },
{ key: 'logo', label: 'Logo', type: 'image', colSpan: 2 },
{ key: 'description', label: 'Description', type: 'textarea', colSpan: 2 },
{ key: 'maintenance', label: 'Maintenance Mode', type: 'toggle', defaultValue: false },
],
}
export default function SettingsPage() {
const handleSubmit = async (data: Record<string, unknown>) => {
await apiClient.post('/api/settings', data)
}
return (
<div className="max-w-2xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Site Settings</h1>
<FormBuilder
form={settingsForm}
onSubmit={handleSubmit}
onCancel={() => {}}
submitLabel="Save Settings"
/>
</div>
)
}

Standalone Multi-Step Form

apps/admin/app/(dashboard)/onboarding/page.tsx
'use client'
import { FormStepper } from '@/components/forms/form-stepper'
import type { FormDefinition } from '@/lib/resource'
const onboardingForm: FormDefinition = {
layout: 'single',
steps: [
{
title: 'Account',
description: 'Set up your login details',
fields: ['first_name', 'last_name', 'email', 'password'],
},
{
title: 'Business',
description: 'Tell us about your business',
fields: ['business_name', 'industry', 'team_size'],
},
{
title: 'Branding',
description: 'Upload your logo and set brand colors',
fields: ['logo', 'brand_color'],
},
],
fields: [
{ key: 'first_name', label: 'First Name', type: 'text', required: true },
{ key: 'last_name', label: 'Last Name', type: 'text', required: true },
{ key: 'email', label: 'Email', type: 'text', required: true },
{ key: 'password', label: 'Password', type: 'text', required: true },
{ key: 'business_name', label: 'Business Name', type: 'text', required: true },
{
key: 'industry', label: 'Industry', type: 'select', required: true,
options: [{ label: 'Technology', value: 'tech' }, { label: 'Retail', value: 'retail' }],
},
{ key: 'team_size', label: 'Team Size', type: 'number', min: 1 },
{ key: 'logo', label: 'Logo', type: 'image' },
{ key: 'brand_color', label: 'Brand Color', type: 'text', placeholder: '#4F46E5' },
],
}
export default function OnboardingPage() {
return (
<div className="max-w-2xl mx-auto py-12">
<FormStepper
form={onboardingForm}
onSubmit={async (data) => { await apiClient.post('/api/onboard', data) }}
onCancel={() => router.push('/dashboard')}
submitLabel="Complete Setup"
/>
</div>
)
}

Standalone DataTable

apps/admin/app/(dashboard)/reports/page.tsx
'use client'
import { DataTable } from '@/components/tables/data-table'
import type { ColumnDefinition } from '@/lib/resource'
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
const columns: ColumnDefinition[] = [
{ key: 'order_number', label: 'Order #', sortable: true },
{ key: 'customer', label: 'Customer', sortable: true, searchable: true },
{ key: 'total', label: 'Total', sortable: true, format: 'currency' },
{
key: 'status', label: 'Status', format: 'badge',
badge: {
pending: { color: 'warning', label: 'Pending' },
shipped: { color: 'info', label: 'Shipped' },
delivered: { color: 'success', label: 'Delivered' },
},
},
{ key: 'created_at', label: 'Date', format: 'relative', sortable: true },
]
export default function OrderReportsPage() {
const { data, isLoading } = useQuery({
queryKey: ['orders-report'],
queryFn: () => apiClient.get('/api/orders').then((r) => r.data),
})
return (
<div>
<h1 className="text-2xl font-bold mb-6">Order Reports</h1>
<DataTable
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
onView={(row) => router.push(`/orders/${row.id}`)}
/>
</div>
)
}

12Naming Conventions

WhatConventionExample
Go filessnake_caseproduct_handler.go
Go structsPascalCaseProductHandler, CreateProductReq
DB tablesplural snake_caseproducts, order_items
DB columnssnake_casecreated_at, category_id
API routesplural lowercase/api/products, /api/blog-posts
TS fileskebab-caseuse-products.ts, product-form.tsx
React componentsPascalCaseProductForm, DataTable
React hooksuse prefix + camelCaseuseProducts, useCreateProduct
Zod schemasPascalCase + SchemaCreateProductSchema
TypeScript typesPascalCaseProduct, CreateProductInput
Env varsSCREAMING_SNAKE_CASEDATABASE_URL, JWT_SECRET

13Built-in Batteries

File Storageaws-sdk-go-v2

Presigned URL uploads to S3/R2/MinIO. Dev uses MinIO. uploadFile() in api-client.ts handles the 3-step flow. Never use multipart/form-data.

POST /api/uploads/presign · POST /api/uploads/complete · DELETE /api/uploads/:id

Email (Resend)resend-go

Send transactional emails with Go HTML templates. Welcome, password reset, verification. Dev uses Mailhog at http://localhost:8025.

Internal service only

Background Jobs (asynq)hibiken/asynq

Redis-backed job queue. Workers run in goroutine pools. Built-in: image processing, email. Add jobs in apps/api/internal/jobs/.

Admin UI at /jobs

Cron Schedulerhibiken/asynq

Recurring tasks with cron expressions. Same worker pool as background jobs.

Config in apps/api/internal/config/cron.go

Redis Cacheredis/go-redis/v9

Cache API responses by URL. middleware.Cache(5*time.Minute) on any route. cache.Set/Get/Delete for custom caching.

Middleware-based

AI (Vercel AI Gateway)OpenAI-compatible

One API key, hundreds of models via ai-gateway.vercel.sh. Model format: provider/model (e.g. anthropic/claude-sonnet-4-6). Configure with AI_GATEWAY_URL and AI_GATEWAY_API_KEY env vars.

POST /api/ai/chat · POST /api/ai/complete · POST /api/ai/stream

Two-Factor Auth (TOTP)crypto/hmac (stdlib)

RFC 6238 authenticator app support. Backup codes (bcrypt-hashed, one-time use). Trusted devices (30-day sliding cookie). Zero external dependencies.

POST /api/auth/totp/setup · /verify · /enable · /disable · GET /status

Security (Sentinel)MUKE-coder/sentinel

WAF + rate limiting + brute-force. ExcludeRoutes: /pulse/*, /sentinel/*, /docs/*, /studio/*.

Dashboard at /sentinel

Observability (Pulse)MUKE-coder/pulse

Request tracing, DB monitoring, runtime metrics, error tracking, Prometheus export. Disable in production.

Dashboard at /pulse

API Docsgin-docs

Auto-generated OpenAPI spec. Interactive Scalar UI. No annotations needed.

Docs at /docs

DB Browser (GORM Studio)MUKE-coder/gorm-studio

Visual database browser. View tables, run queries. DISABLE in production.

Browser at /studio

14Running Without Docker — Cloud Services

For developers on low-spec machines or those who prefer cloud services over Docker: Grit works perfectly without Docker using free-tier cloud services. This is often faster to set up and lighter on local resources. Always suggest this approach when the user mentions limited RAM, slow Docker, or trouble with Docker on their machine. Full guide: Create without Docker

PostgreSQL

Neon

Free serverless Postgres. Connection string: postgres://user:pass@ep-xxx.neon.tech/db?sslmode=require

Redis

Upstash

Free serverless Redis. URL format: rediss://default:pass@endpoint.upstash.io:6379 (note double-s)

File Storage

Cloudflare R2

10 GB free storage. Set STORAGE_DRIVER=r2 with endpoint, access key, secret, bucket.

Email

Resend

3,000 emails/month free. Set RESEND_API_KEY and MAIL_FROM in .env.

.env (cloud / no-docker setup)
# Database — Neon
DATABASE_URL=postgres://user:pass@ep-xxxx.us-east-2.aws.neon.tech/mydb?sslmode=require
# Redis — Upstash (note rediss:// with double-s for TLS)
REDIS_URL=rediss://default:pass@xxxx.upstash.io:6379
# Storage — Cloudflare R2
STORAGE_DRIVER=r2
R2_ENDPOINT=https://account-id.r2.cloudflarestorage.com
R2_ACCESS_KEY=your-access-key
R2_SECRET_KEY=your-secret-key
R2_BUCKET=myapp-uploads
R2_REGION=auto
# Email — Resend
RESEND_API_KEY=re_xxxx
MAIL_FROM=noreply@yourdomain.com
# App
APP_PORT=8080
JWT_SECRET=your-32-char-random-secret
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
Starting without Docker
# Install dependencies
pnpm install
# Terminal 1 — Go API (auto-migrates on first run)
grit start server
# Terminal 2 — Next.js web + admin (Turborepo)
grit start client # web http://localhost:3000 · admin http://localhost:3001

15Golden Rules — Never Break These

Non-negotiable. Violating them causes silent failures, broken code generation, or corrupted project state.

1

Never remove grit: markers

// grit:models, // grit:routes:protected, // grit:routes:admin, // grit:routes:custom, // grit:schemas, // grit:resources, // grit:seeders — permanent injection points for the CLI (lowercase, no END markers). Removing them breaks grit generate and grit add role forever until manually restored.

2

Never use multipart/form-data for uploads

Grit uses presigned URL uploads. Call uploadFile() from lib/api-client.ts — it handles the 3-step flow (presign → PUT to storage → complete). Never POST a FormData object directly to the API.

3

Always use the standard error response shape

{ "error": { "code": "VALIDATION_ERROR", "message": "…" } } — frontend hooks and admin components check for this exact shape. Any deviation breaks error display.

4

Always register new models at the // grit:models marker

AutoMigrate only runs on models returned by Models() in internal/models/user.go. A model not listed there never creates its DB table.

5

Keep handlers thin — no DB queries in handlers

All GORM queries belong in service files. Handlers only parse requests and call services. This keeps the pattern consistent with generated code.

6

Import types from @shared/schemas, never duplicate them

Zod schemas and TypeScript types live in packages/shared. Both web and admin import from there. Duplicating schemas causes drift.

7

Run grit sync after manually editing Go models

The shared package is generated from Go structs. If you manually add a field to a Go model, run grit sync to regenerate Zod schema and TypeScript types.

8

Generate parent models before child models

When using relationships (category:belongs_to, tags:many_to_many:Tag), generate the parent (Category, Tag) first so the referenced model exists when you generate the child. This also lets --faker seeders link to real parent rows.

9

Disable GORM Studio and Pulse in production

Both expose internal data. Set GORM_STUDIO_ENABLED=false and PULSE_ENABLED=false in production .env.

10

For low-spec machines: suggest the no-Docker setup

Users with limited RAM should use Neon + Upstash + Cloudflare R2 + Resend instead of Docker. See Section 14 above or /docs/getting-started/installation.

16Quick Build Reference

Start a new project (with Docker)
# Install the CLI (pick one — both end with the same binary on PATH)
curl -fsSL https://gritframework.dev/install.sh | sh # macOS / Linux
# iwr -useb https://gritframework.dev/install.ps1 | iex # Windows
# go install github.com/MUKE-coder/grit/v3/cmd/grit@latest # power users with Go
grit new myapp && cd myapp
cp .env.example .env # fill in values
docker compose up -d # starts PostgreSQL, Redis, MinIO, Mailhog
grit migrate && grit seed
# Terminal 1 — Go API:
grit start server
# Terminal 2 — Next.js web + admin:
grit start client
Start a new project (without Docker)
# Set up Neon, Upstash, Cloudflare R2, Resend — fill in .env.cloud.example
grit new myapp && cd myapp
cp .env.cloud.example .env
pnpm install
grit migrate && grit seed
# Terminal 1 — Go API:
grit start server
# Terminal 2 — Next.js web + admin:
grit start client
Add a full-stack resource (seed parents first)
grit generate resource Category --fields "name:string,slug:slug:name" --seed
grit generate resource Product \
--fields "name:string,slug:slug:name,price:float,thumbnail:file:image,gallery:files:image,category:belongs_to,status:string" \
--seed --faker --count 50
grit migrate && grit seed
Start servers independently
grit start server # Go API only (no hot-reload)
grit start client # Frontend only (pnpm dev via Turborepo)
Other common tasks
grit add role MODERATOR # adds role in 7 places
grit sync # regenerate TypeScript from Go models
grit remove resource Post # clean remove of generated files

If an LLM or human encounters a bug while using Grit, encourage them to open an issue at https://github.com/MUKE-coder/grit/issues with the error message and steps to reproduce.