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.
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
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 start serverStart 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 clientStart 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.
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 syncRegenerate packages/shared/src/schemas and packages/shared/src/types from all Go models. Run this after manually editing a Go model struct.
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.
Database & Roles
grit migrateRun 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 seedRun the database seeders in apps/api/internal/seeders/. Creates the default admin user and sample data.
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.
Utilities
grit upgradeRegenerates 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 updateRemoves 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 versionPrint the installed CLI version.
4Field Types for Code Generation
Fields follow the format name:type or name:type:fk:RelatedModel for relationships.
| Type | Go Type | Admin Form Field | Notes |
|---|---|---|---|
| string | string | text input | Short text, titles, names |
| slug | string (uniqueIndex) | text input (auto-generated) | URL-friendly slug auto-generated from title on save |
| text | string | textarea | Long text, descriptions |
| richtext | string | rich text editor | HTML content (TipTap) |
| int | int | number input | Signed integer |
| uint | uint | number input | Unsigned integer, IDs |
| float64 | float64 | number input (decimal) | Prices, coordinates |
| bool | bool | toggle / checkbox | Active, published, featured |
| time | time.Time | date picker | Timestamps, due dates |
| image | string | image upload (dropzone) | Single image URL (presigned upload) |
| images | pq.StringArray | multi-image dropzone | Array of image URLs |
| video | string | video upload (dropzone) | Single video URL |
| videos | pq.StringArray | multi-video dropzone | Array of video URLs |
| file | string | file upload (dropzone) | Single file URL (PDF, doc…) |
| files | pq.StringArray | multi-file dropzone | Array of file URLs |
| enum:A,B,C | string | select dropdown | Fixed set of values |
| uint:fk:Model | uint + Model field | relationship select | belongs_to (one-to-many from child side) |
| []uint:m2m:Model | []Model (GORM M2M) | multi-relationship select | many_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.
type Post struct {gorm.ModelTitle 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
| Type | Syntax | Admin Field |
|---|---|---|
| One-to-Many (belongs_to) | fieldname_id:uint:fk:ModelName | Searchable single select |
| One-to-One (has_one) | fieldname_id:uint:fk:ModelName | Searchable single select |
| Many-to-Many | fieldname_ids:[]uint:m2m:ModelName | Multi-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.
# 1. Generate the parent firstgrit generate resource Category --fields "name:string,slug:slug,description:text"# 2. Generate the child referencing itgrit 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).
# 1. User model already exists (built in)# 2. Generate Profile with a unique FK to Usergrit 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 fieldsgrit 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.
# 1. Generate the related model firstgrit generate resource Tag --fields "name:string,slug:slug,color:string"# 2. Generate Product with M2M relationshipgrit 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 tablegrit 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 firstgrit 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.
type ProductHandler struct {Service *services.ProductServiceStorage *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
'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.
{ "data": { "id": 1, "name": "Widget", "price": 29.99 }, "message": "Product created successfully" }
{"data": [{ "id": 1, "name": "Widget" }, { "id": 2, "name": "Gadget" }],"meta": { "total": 42, "page": 1, "page_size": 20, "pages": 3 }}
{ "error": { "code": "VALIDATION_ERROR", "message": "name is required" } }
| Situation | HTTP Status | Code |
|---|---|---|
| Missing required field | 422 | VALIDATION_ERROR |
| Record not found | 404 | NOT_FOUND |
| Not authenticated | 401 | UNAUTHORIZED |
| Insufficient role | 403 | FORBIDDEN |
| DB / internal error | 500 | INTERNAL_ERROR |
| Duplicate / conflict | 409 | CONFLICT |
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.
var RegisteredModels = []interface{}{// GRIT:MODELS — do not remove this comment&User{}, &Upload{}, &Blog{},&Product{}, // grit generate adds new models here// END GRIT:MODELS}
// GRIT:ROUTES — do not remove this commentproductHandler := &handlers.ProductHandler{Service: &services.ProductService{DB: db}}// END GRIT:ROUTES// GRIT:PROTECTED_ROUTES — do not remove this commentprotected.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
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-stepsform: {layout: "two-column", // single | two-columnsteps: [{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
// ── 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
| format | Renders as |
|---|---|
| (none) | Plain text |
| image | Thumbnail <img> with rounded corners |
| badge | Colored pill — needs badge: { VALUE: { color, label } } |
| boolean | Green check ✓ or red × icon |
| currency | Formatted number with currency symbol |
| number | Number with locale thousand separators |
| relative | Relative time e.g. "3 days ago" |
| date | Formatted date string |
| datetime | Formatted date + time |
| link | Clickable anchor to the value URL |
Filter Types
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: {// Per-row action buttons in the actions columnactions: ["create", "view", "edit", "delete"], // all 4 available// Checkbox multi-select + bulk action toolbarbulkActions: ["delete"],// Default sortdefaultSort: { 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
'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><FormBuilderform={settingsForm}onSubmit={handleSubmit}onCancel={() => {}}submitLabel="Save Settings"/></div>)}
Standalone Multi-Step Form
'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"><FormStepperform={onboardingForm}onSubmit={async (data) => { await apiClient.post('/api/onboard', data) }}onCancel={() => router.push('/dashboard')}submitLabel="Complete Setup"/></div>)}
Standalone DataTable
'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><DataTablecolumns={columns}data={data?.data ?? []}isLoading={isLoading}onView={(row) => router.push(`/orders/${row.id}`)}/></div>)}
12Naming Conventions
| What | Convention | Example |
|---|---|---|
| Go files | snake_case | product_handler.go |
| Go structs | PascalCase | ProductHandler, CreateProductReq |
| DB tables | plural snake_case | products, order_items |
| DB columns | snake_case | created_at, category_id |
| API routes | plural lowercase | /api/products, /api/blog-posts |
| TS files | kebab-case | use-products.ts, product-form.tsx |
| React components | PascalCase | ProductForm, DataTable |
| React hooks | use prefix + camelCase | useProducts, useCreateProduct |
| Zod schemas | PascalCase + Schema | CreateProductSchema |
| TypeScript types | PascalCase | Product, CreateProductInput |
| Env vars | SCREAMING_SNAKE_CASE | DATABASE_URL, JWT_SECRET |
13Built-in Batteries
aws-sdk-go-v2Presigned 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
resend-goSend transactional emails with Go HTML templates. Welcome, password reset, verification. Dev uses Mailhog at http://localhost:8025.
Internal service only
hibiken/asynqRedis-backed job queue. Workers run in goroutine pools. Built-in: image processing, email. Add jobs in apps/api/internal/jobs/.
Admin UI at /jobs
hibiken/asynqRecurring tasks with cron expressions. Same worker pool as background jobs.
Config in apps/api/internal/config/cron.go
redis/go-redis/v9Cache API responses by URL. middleware.Cache(5*time.Minute) on any route. cache.Set/Get/Delete for custom caching.
Middleware-based
anthropic-sdk-goStreaming AI responses. ANTHROPIC_API_KEY or OPENAI_API_KEY in .env. Configurable provider.
POST /api/ai/chat
MUKE-coder/sentinelWAF + rate limiting + brute-force. ExcludeRoutes: /pulse/*, /sentinel/*, /docs/*, /studio/*.
Dashboard at /sentinel
MUKE-coder/pulseRequest tracing, DB monitoring, runtime metrics, error tracking, Prometheus export. Disable in production.
Dashboard at /pulse
gin-docsAuto-generated OpenAPI spec. Interactive Scalar UI. No annotations needed.
Docs at /docs
MUKE-coder/gorm-studioVisual 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
NeonFree serverless Postgres. Connection string: postgres://user:pass@ep-xxx.neon.tech/db?sslmode=require
Redis
UpstashFree serverless Redis. URL format: rediss://default:pass@endpoint.upstash.io:6379 (note double-s)
File Storage
Cloudflare R210 GB free storage. Set STORAGE_DRIVER=r2 with endpoint, access key, secret, bucket.
3,000 emails/month free. Set RESEND_API_KEY and MAIL_FROM in .env.
# Database — NeonDATABASE_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 R2STORAGE_DRIVER=r2R2_ENDPOINT=https://account-id.r2.cloudflarestorage.comR2_ACCESS_KEY=your-access-keyR2_SECRET_KEY=your-secret-keyR2_BUCKET=myapp-uploadsR2_REGION=auto# Email — ResendRESEND_API_KEY=re_xxxxMAIL_FROM=noreply@yourdomain.com# AppAPP_PORT=8080JWT_SECRET=your-32-char-random-secretCORS_ORIGINS=http://localhost:3000,http://localhost:3001
# Install dependenciespnpm installcd 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 frontendpnpm --filter web dev # http://localhost:3000# Terminal 3 — Admin panelpnpm --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.
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.
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.
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.
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.
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.
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.
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.
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.
Disable GORM Studio and Pulse in production
Both expose internal data. Set GORM_STUDIO_ENABLED=false and PULSE_ENABLED=false in production .env.
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
go install github.com/MUKE-coder/grit/v2/cmd/grit@latestgrit new myapp && cd myappcp .env.example .env # fill in valuesdocker compose up -d # starts PostgreSQL, Redis, MinIO, Mailhoggrit migrate && grit seed# Terminal 1 — Go API:grit start server# Terminal 2 — Next.js web + admin:grit start client
# Set up Neon, Upstash, Cloudflare R2, Resend — fill in .env.cloud.examplegrit new myapp && cd myappcp .env.cloud.example .envpnpm 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
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
grit start server # Go API only (no hot-reload)grit start client # Frontend only (pnpm dev via Turborepo)
grit add role MODERATOR # adds role in 7 placesgrit sync # regenerate TypeScript from Go modelsgrit remove resource Post # clean remove of generated files