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
│ │ │ └── models.go # // GRIT:MODELS marker — NEVER remove
│ │ ├── handlers/ # Thin HTTP handlers (call services)
│ │ ├── services/ # Business logic + GORM queries
│ │ ├── routes/ # Route registration
│ │ │ └── routes.go # // GRIT:ROUTES marker — NEVER remove
│ │ ├── jobs/ # asynq background jobs
│ │ └── seeders/ # DB seed data
│ ├── 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 go install github.com/MUKE-coder/grit/v2/cmd/grit@latest. Every command is idempotent — safe to re-run.

Project Lifecycle

grit new <app-name>

Scaffold a complete new project: Go API, Next.js web, Next.js admin, shared package, Docker Compose, .env, GRIT_SKILL.md, and all config files. Flags: --full (default, all apps), --api (API only, no frontend), --mobile (include Expo mobile app), --style default|modern|minimal|glass.

$ grit new myapp
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. Alternatively, pnpm dev from the project root starts all apps via Turborepo.

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 pairs. Special types: slug:slug (auto-generated from title), image/images/video/videos/file/files (presigned upload), enum:A,B,C (select), uint:fk:Model (belongs_to), []uint:m2m:Model (many_to_many). Extra flags: --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,price:float64,image:image"
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 the database seeders in apps/api/internal/seeders/. Creates the default admin user and sample data.

$ 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 version

Print the installed CLI version.

$ grit version

4Field Types for Code Generation

Fields follow the format name:type or name:type:fk:RelatedModel for relationships.

TypeGo TypeAdmin Form FieldNotes
stringstringtext inputShort text, titles, names
slugstring (uniqueIndex)text input (auto-generated)URL-friendly slug auto-generated from title on save
textstringtextareaLong text, descriptions
richtextstringrich text editorHTML content (TipTap)
intintnumber inputSigned integer
uintuintnumber inputUnsigned integer, IDs
float64float64number input (decimal)Prices, coordinates
boolbooltoggle / checkboxActive, published, featured
timetime.Timedate pickerTimestamps, due dates
imagestringimage upload (dropzone)Single image URL (presigned upload)
imagespq.StringArraymulti-image dropzoneArray of image URLs
videostringvideo upload (dropzone)Single video URL
videospq.StringArraymulti-video dropzoneArray of video URLs
filestringfile upload (dropzone)Single file URL (PDF, doc…)
filespq.StringArraymulti-file dropzoneArray of file URLs
enum:A,B,Cstringselect dropdownFixed set of values
uint:fk:Modeluint + Model fieldrelationship selectbelongs_to (one-to-many from child side)
[]uint:m2m:Model[]Model (GORM M2M)multi-relationship selectmany_to_many join table

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 (not slug:string) as the field type. The Go service auto-generates the slug from the title using a slugify helper before saving, and GORM adds a uniqueIndex automatically.

apps/api/internal/models/post.go (generated)
type Post struct {
gorm.Model
Title string `gorm:"not null" json:"title"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Content string `gorm:"type:text" json:"content"`
Published bool `gorm:"default:false" json:"published"`
}
// Service auto-generates slug before saving:
// slug = strings.ToLower(strings.ReplaceAll(title, " ", "-"))
// The public GetBySlug handler route: GET /api/posts/slug/:slug

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

Understanding Relationships

TypeSyntaxAdmin Field
One-to-Many (belongs_to)fieldname_id:uint:fk:ModelNameSearchable single select
One-to-One (has_one)fieldname_id:uint:fk:ModelNameSearchable single select
Many-to-Manyfieldname_ids:[]uint:m2m:ModelNameMulti-select tag input

One-to-Many (belongs_to)

A child record belongs to one parent. Syntax: fieldname_id:uint:fk:ModelName. Use the parent model name in PascalCase after fk:. This adds a CategoryID uint FK column and a Category Category preload field to the struct. 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,description:text"
# 2. Generate the child referencing it
grit generate resource Post \
--fields "title:string,slug:slug,content:richtext,category_id:uint:fk:Category,is_published:bool"
grit migrate

One-to-One (has_one)

Exactly one child per parent. Uses the same FK syntax as belongs_to — fieldname_id:uint:fk:ModelName — but the CLI automatically adds a uniqueIndex on the FK column. GORM treats it as one-to-one when the FK has a unique constraint. 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 unique FK to User
grit generate resource Profile \
--fields "bio:text,avatar:image,website:string,user_id:uint:fk:User"
# The CLI adds uniqueIndex on user_id automatically for one-to-one FK fields
grit migrate

Many-to-Many

A record can belong to many of another, and vice versa. Syntax: fieldname_ids:[]uint:m2m:ModelName. Key differences from belongs_to: the field name is plural (e.g. tag_ids), the type prefix is []uint (not uint), and the keyword is m2m (not fk). 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,color:string"
# 2. Generate Product with M2M relationship
grit generate resource Product \
--fields "name:string,slug:slug,price:float64,thumbnail:image,tag_ids:[]uint:m2m: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 with an enum status.

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

Blog Post — Slug + Single Image + Rich Text

Use slug:slug for SEO-friendly URLs. The service layer auto-generates the slug from the title on save.

grit generate resource Post \
--fields "title:string,slug:slug,excerpt:text,content:richtext,cover_image:image,status:enum:draft,published,archived,published_at:time,views:int"
grit migrate

Product — Multiple Images + Gallery + Slug

images generates a pq.StringArray stored as a PostgreSQL array. The admin shows a multi-upload dropzone.

grit generate resource Product \
--fields "name:string,slug:slug,description:text,price:float64,compare_price:float64,sku:string,stock:int,thumbnail:image,gallery:images,is_featured:bool,is_active:bool,status:enum:draft,published,out_of_stock"
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,description:text,image:image"
grit generate resource Tag --fields "name:string,slug:slug,color:string"
# Step 2: generate the child with FK (belongs_to) and M2M (many_to_many)
grit generate resource Article \
--fields "title:string,slug:slug,content:richtext,cover_image:image,category_id:uint:fk:Category,tag_ids:[]uint:m2m:Tag,author_id:uint:fk:User,is_published:bool,published_at:time"
grit migrate

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

Course + Lessons — Video + Videos Array

video for a single video, videos for multiple videos array. 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,description:text,thumbnail:image,intro_video:video,price:float64,level:enum:beginner,intermediate,advanced,is_published:bool"
# Lesson (child — belongs to Course via course_id:uint:fk:Course)
grit generate resource Lesson \
--fields "title:string,slug:slug,description:text,video_url:video,duration:int,position:int,is_preview:bool,course_id:uint:fk:Course,attachments:files"
grit migrate

E-Commerce Order — Full Complexity

grit generate resource Order \
--fields "order_number:string,status:enum:pending,processing,shipped,delivered,cancelled,refunded,subtotal:float64,tax:float64,shipping_fee:float64,total:float64,notes:text,shipping_address:text,payment_method:enum:card,mobile_money,cash,payment_status:enum:unpaid,paid,refunded,paid_at:time,shipped_at:time,delivered_at:time,customer_id:uint:fk: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/models.go
var RegisteredModels = []interface{}{
// GRIT:MODELS — do not remove this comment
&User{}, &Upload{}, &Blog{},
&Product{}, // grit generate adds new models here
// END GRIT:MODELS
}
apps/api/internal/routes/routes.go
// GRIT:ROUTES — do not remove this comment
productHandler := &handlers.ProductHandler{Service: &services.ProductService{DB: db}}
// END GRIT:ROUTES
// GRIT:PROTECTED_ROUTES — do not remove this comment
protected.GET("/products", productHandler.List)
protected.POST("/products", productHandler.Create)
// END GRIT:PROTECTED_ROUTES

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 (Claude / OpenAI)anthropic-sdk-go

Streaming AI responses. ANTHROPIC_API_KEY or OPENAI_API_KEY in .env. Configurable provider.

POST /api/ai/chat

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
cd apps/api && go mod tidy && cd ../..
# Terminal 1 — Go API (auto-migrates on first run)
cd apps/api && go run cmd/api/main.go
# Terminal 2 — Web frontend
pnpm --filter web dev # http://localhost:3000
# Terminal 3 — Admin panel
pnpm --filter admin dev # 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, // END GRIT:MODELS, // GRIT:ROUTES, // END GRIT:ROUTES — permanent injection points for the CLI. 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 between the GRIT:MODELS markers

AutoMigrate only runs on models in RegisteredModels. A model not listed here 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 FK relationships (category_id:uint:fk:Category), generate Category first so the referenced model exists when you generate Product.

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/create-without-docker.

16Quick Build Reference

Start a new project (with Docker)
go install github.com/MUKE-coder/grit/v2/cmd/grit@latest
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 && cd apps/api && go mod tidy && cd ../..
# Terminal 1: cd apps/api && go run cmd/api/main.go
# Terminal 2: pnpm --filter web dev
# Terminal 3: pnpm --filter admin dev
Add a full-stack resource
grit generate resource Product \
--fields "name:string,slug:slug,price:float64,thumbnail:image,gallery:images,category_id:uint:fk:Category,status:enum:draft,published"
grit migrate
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