You'll build a small task tracker end to end: a typed API, a migration, a generated TypeScript client, a React page, cookie login, and the same model running in the admin. That's the core CRUD loop.
Time: about 45 minutes. Prerequisites: installation.md — Go 1.26+, Node 22+, Atlas, and a C toolchain (SQLite is cgo-only).
Every chapter ends with a ✅ Checkpoint telling you what "it worked" looks like. If a checkpoint fails, stop there — later chapters build on it.
The finished application is committed at
examples/tutorial/ and compiled by CI, so it can't
silently rot.
| # | Chapter |
|---|---|
| 1 | Scaffold the project |
| 2 | Run it |
| 3 | Your first model and migration |
| 4 | Generate the resource |
| 5 | The contract |
| 6 | The typed client |
| 7 | The frontend |
| 8 | Auth |
| 9 | The admin |
| 10 | A management command |
| 11 | Ship it |
| 12 | Where next |
gombit new tasks --database sqlite --auth cookie --ui mui
cd tasksFour choices, recorded in gombit.yaml:
| Flag | Value | Why |
|---|---|---|
--database |
sqlite |
zero setup; swap to postgres or mysql later via config |
--auth |
cookie |
the admin requires session cookies (ADR-013). The API default is Bearer JWT (--auth jwt) |
--ui |
mui |
scaffolds Material UI CRUD screens. minimal is the headless default |
--module |
derived | defaults to github.com/example/tasks; pass --module to set your own |
The generated tree:
tasks/
├── cmd/
│ ├── server/main.go # your application entrypoint
│ └── gombit/main.go # your CLI, with app-registered commands
├── config/README.md
├── database/
│ ├── migrations/ # versioned SQL, written by gombit db makemigrations
│ └── seeds/
├── frontend/ # Vite + React + TypeScript
│ └── src/api/generated/ # generated client — never hand-edit
├── internal/
│ ├── platform/database.go # AutoMigrate model list
│ ├── product/ # example feature package
│ └── web/embed.go # go:embed target for --embed builds
├── go.mod
└── gombit.yaml
The law here: features live in internal/<feature>/ with their model,
handler, and routes together. No app/controllers, no app/models. product/
is a worked example — delete it once you've read it.
gombit newwrites a separate Go module. It is not part of the framework repository, and nothing is committed to Gombit when you run it.
✅ Checkpoint
go build ./...Compiles with no output. gombit new pinned go.mod to its own framework
version and ran go mod tidy, so there is nothing to wire up by hand.
Building the CLI from source instead of installing it? Then it reports
dev, which the module proxy can't resolve, andgombit newtells you to add areplace— see installation.md.
gombit devgombit dev runs the Go API and the Vite dev server together, proxies /api,
/openapi.json, /docs, and /admin to the backend, and regenerates the TypeScript
client whenever the live spec changes. It prints a service table with the URLs.
Open:
- http://127.0.0.1:5173 — the React app
- http://127.0.0.1:8080/docs — interactive API docs
- http://127.0.0.1:8080/openapi.json — the OpenAPI 3.1 document
/docs is on when GOMBIT_DOCS_ENABLED is set, which is the local default.
Turn it off in production.
✅ Checkpoint
curl -s http://127.0.0.1:8080/livezReturns a success response. Leave gombit dev running in its own terminal for
the rest of the tutorial.
Create internal/task/task.go:
package task
import "time"
// Task is the feature-package GORM model.
type Task struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
Title string `gorm:"size:255;not null"`
Done bool
}ID is an auto-increment key GORM assigns, and CreatedAt / UpdatedAt are
set on write. There is no soft-delete DeletedAt: deleting a task removes the
row, and the database's foreign keys decide what that does to rows that
reference it (ADR-019).
Now generate the SQL. makemigrations takes the model type explicitly — it
doesn't scan your project:
gombit db makemigrations create_tasks --model github.com/example/tasks/internal/task.TaskGombit wraps Atlas in Program Mode: it loads your GORM
models, diffs them against the current schema, and writes versioned SQL you
can read and edit. There is no hand-rolled migration DSL, and no
AutoMigrate-in-production.
Read the file it wrote under database/migrations/ before applying it. Then:
gombit db migrate
gombit db statusstatus lists applied and pending revisions. gombit db rollback undoes the
last batch.
gombit new already seeded and applied one migration ahead of yours: a
bootstrap migration for the framework's own auth tables (see
migrations.md) — check
gombit db status right now and you'll see it there already, in batch 1,
before you've touched db migrate yourself. Your create_tasks migration
lands in its own batch.
✅ Checkpoint — gombit db status shows bootstrap (batch 1) and
create_tasks (batch 2) as applied.
If you get
atlas: executable file not found in $PATH, install Atlas Community Edition (see installation.md). The GORM model is still loader-ready without it.
Now let the generator build the API around that model:
gombit make resource Task title:string:required done:boolkeep internal/task/task.go
create internal/task/.gombit-resource
modify cmd/server/main.go
modify internal/platform/database.go
create frontend/src/task/form.tsx
create frontend/src/task/list.tsx
modify frontend/src/resources.tsx
write internal/task/dto.gen.go
write internal/task/handler.gen.go
seed internal/task/hooks.go
make resource is model-first (see
ADR-016): the model is the
human-owned source of truth, and everything else is derived from it.
- Your hand-written
task.gois kept — the model is yours, scaffolded once and never overwritten (that is why there is no clobber prompt;--forcere-scaffolds it from the CLI spec if you want). dto.gen.goandhandler.gen.goare generator-owned (DO NOT EDIT): the request/response DTOs, mappers, and the Huma list/get/create handler, regenerated from the model bygombit generate, whichmake resourcejust ran for you.hooks.gois yours — a default no-opBeforeCreatewhere you set server-managed values. Seeded once, never overwritten.
The field grammar is name:type[:modifier[,modifier…]]. The type is any kind
in fields.md: scalars such as string, int, decimal, date,
uuid, and email, enum(draft=Draft,published=Published), or a relation.
Modifiers are required / nullable, unique, index, the query modifiers
filterable, sortable, searchable, and aggregatable, and the constraints
default=, min= / max=, max_length=, and regex=. The query modifiers
become gombit:"..." policy on the model field and opt it into the list
handler's declared query surface — see the
list query section. A numeric
field can also be aggregatable for server-side ?aggregate=sum:<field> totals
in meta.aggregates — see numeric aggregates.
Two properties of every Gombit generator matter here:
- Go source is modified through
go/ast, never regex.Register(app)is inserted intocmd/server/main.gonext to the existingproduct.Registercall, and the AutoMigrate list is updated the same way. - Regeneration is the synchronization mechanism. Change the model and re-run
gombit generate; the*.gen.goare re-derived, andgombit generate --checkfails if a committed copy is stale.--dry-runpreviews a run without writing.
--service and --repo add pass-through service/repository layers. The
default is a thin generated handler straight over GORM — add the layers when you
have a reason, not by default.
The generated handler.gen.go shows what was mounted:
huma.Register(api, huma.Operation{
OperationID: "list-tasks",
Method: http.MethodGet,
Path: prefix + "/tasks",
Summary: "List tasks",
Tags: []string{"Tasks"},
}, h.list)Routes are registered explicitly. Gombit never discovers feature packages by reflection.
✅ Checkpoint
curl -s http://127.0.0.1:8080/api/v1/tasks{"data":[],"meta":{"page":1,"per_page":20,"total":0}}That shape is the D10 envelope: success is {"data": ..., "meta"?: ...} and
nothing else — the response body carries no extra keys.
Handlers are Huma-typed, and the handler signature is the source of truth for
the API contract — but you don't hand-write it. It is derived from the model
into handler.gen.go / dto.gen.go, and OpenAPI is emitted from that code,
never hand-written.
From the generated dto.gen.go / handler.gen.go:
type taskCreateBody struct {
Title string `json:"title" minLength:"1" maxLength:"255" doc:"Title"`
Done bool `json:"done" doc:"Done"`
}
type createTaskInput struct {
Body taskCreateBody
}
type createTaskOutput struct {
Body contract.Data[taskData]
}minLength (a NOT NULL string) and maxLength (the column size) are derived
from the model, land in the OpenAPI schema, and are enforced at
request time. Validation failures come back in the D10 error envelope with
per-field detail:
{
"error": {
"code": "validation_error",
"message": "The request contains invalid fields.",
"fields": {
"done": ["expected required property done to be present"],
"title": ["expected length >= 1"]
},
"request_id": "5db935cd-7c74-4ffe-a4de-0fa817451f54"
}
}request_id is on every error, which is what makes a production report
traceable to a log line.
Errors are constructed with contract helpers rather than raw status codes.
Generated get/create handlers classify GORM errors through
database.MapLoadError / database.MapPersistError: a missing row is D10
not_found, a unique violation is conflict, a value the database refuses
as data (out of range, too long) is validation_error, and any other driver
failure is internal.
return nil, contract.WithContext(ctx, contract.NotFound("task not found"))Inspect what you've mounted. Bare gombit routes only prints framework
routes (/livez, /docs, and so on) — pass --url to see the feature
routes gombit dev is actually serving:
gombit routes --url http://127.0.0.1:8080
gombit openapi generate --out openapi.jsonNeed something Huma can't express? app.Router() returns the raw
*gin.Engine. It's a tested, first-class escape hatch — not a workaround.
✅ Checkpoint — /tasks appears in the /docs UI with title marked
required.
gombit client generateThis reads the OpenAPI document and writes a TypeScript client into
frontend/src/api/generated/. Never hand-edit that directory — it is
overwritten.
The important command is the drift check:
gombit client check --url http://127.0.0.1:8080/openapi.jsonIt fails if the committed client no longer matches the live contract. gombit
is a separately compiled binary, so outside the framework's own repository it
has no Go-level access to your API — --url fetches the live /openapi.json
from a running app (gombit dev or your deployed API) instead. Wire it into
your own CI against a running instance of your app: it's what stops a backend
change from silently breaking the frontend. gombit dev regenerates the
client automatically while it's running.
✅ Checkpoint — frontend/src/api/generated/schema.ts contains a task
type with title and done.
gombit make resource already scaffolded frontend/src/task/list.tsx and
form.tsx, wired into frontend/src/resources.tsx. With --ui mui they're
Material UI screens; with minimal, headless equivalents.
Two things worth reading in that generated code:
Types come from the generated client, so a backend rename becomes a TypeScript error rather than a runtime surprise.
Server validation maps into the form. The fields object from the D10
error envelope is fed into React Hook Form, so field errors from Go render next
to the right inputs — one validation source of truth, not two. Required
fields also get a client-side rule with its own message ("Title is required"), so an empty submit shows text immediately, before round-tripping
to the server at all.
No secrets in frontend source. Anything under
VITE_*is compiled into the bundle and is public. Treat it that way.
You scaffolded with --auth cookie, so RequireAuth guards every route
except /login — visiting /tasks right now redirects there. The next
chapter sets up a login; come back and finish this checkpoint after it.
✅ Checkpoint — once you can log in (chapter 8), the task list renders at http://127.0.0.1:5173, and submitting an empty title shows a "Title is required" inline error next to the field.
You scaffolded with --auth cookie, so sessions are HttpOnly cookies plus CSRF
double-submit. (The API default, --auth jwt, keeps the access token in
memory — never localStorage.)
gombit new already wrote a random GOMBIT_JWT_SECRET into .env (cookie
mode signs its CSRF token with the same secret), and every gombit command
run from this directory — including the gombit dev you started in chapter
2 — loads .env automatically. Create an admin account:
gombit createsuperuser --email admin@example.comThe secret is required: without it Bearer auth stays unmounted and nobody
could log in. createsuperuser prompts for the password when you leave
--password off — better than putting it in your shell history.
.envis gitignored and never read outside this directory. A real deployment has no.envfile and setsGOMBIT_JWT_SECRETthrough its own environment instead — see Ship it.
Under cookie auth, every state-changing request needs a CSRF token. The full flow:
# 1. Get a CSRF token (and its cookie).
curl -s -c jar.txt http://127.0.0.1:8080/api/v1/auth/csrf
CSRF=$(grep -i csrf jar.txt | awk '{print $7}')
# 2. Log in.
curl -s -b jar.txt -c jar.txt -X POST http://127.0.0.1:8080/api/v1/auth/login \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
-d '{"email":"admin@example.com","password":"correct-horse-battery-staple"}'
# 3. Now writes are accepted.
CSRF=$(grep -i csrf jar.txt | awk '{print $7}')
curl -s -b jar.txt -X POST http://127.0.0.1:8080/api/v1/tasks \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
-d '{"title":"Write the tutorial","done":false}'{"data":{"id":1,"created_at":"2026-01-02T15:04:05Z","updated_at":"2026-01-02T15:04:05Z","title":"Write the tutorial","done":false}}The response carries created_at/updated_at: the model-first response DTO is
derived from the model, so its readable timestamps come through automatically.
Skip the token and you get a clean refusal rather than a mutation:
{"error":{"code":"authorization","message":"csrf token missing or invalid","request_id":"..."}}The browser app handles all of this for you — RequireAuth guards routes and
the client attaches the header. The threat model is written up in
auth-cookie.md.
✅ Checkpoint — you can log in from the UI, and a POST without the CSRF
header is rejected.
This is the part no other Go framework gives you: a runtime admin over an explicit registry. Not generated pages you maintain, and not request-time reflection over your models.
Create internal/task/admin.go:
package task
import (
"github.com/gombit-dev/gombit/admin"
"github.com/gombit-dev/gombit/framework"
)
func RegisterAdmin(app *framework.App) error {
return admin.Register(app, Task{}, admin.Options{
Slug: "tasks",
Singular: "Task",
Plural: "Tasks",
Fields: []admin.Field{
{Name: "id", Type: admin.TypeInteger, ReadOnly: true},
{Name: "title", Type: admin.TypeString, Required: true},
{Name: "done", Type: admin.TypeBoolean},
},
List: []string{"title", "done"},
Search: []string{"title"},
Ordering: []string{"title", "created_at"},
Actions: admin.Actions{
List: true, Detail: true, Create: true, Update: true, Delete: true,
},
})
}Call it from cmd/server/main.go, next to task.Register(app):
if err := task.RegisterAdmin(app); err != nil {
log.Fatal(err)
}Registration is explicit and typed — Options is concrete data, resolved once
at startup. Leave Fields empty and they're derived from the struct at
registration time, still once, never per request.
Now visit http://127.0.0.1:5173/admin/ (Vite proxies /admin to the Go
server) or http://127.0.0.1:8080/admin/ and log in as your superuser. The
SPA is framework-owned and served from the binary; you don't build or maintain
it.
Behind it are two API surfaces:
# What models exist, and what can be done with them.
curl -s -b jar.txt http://127.0.0.1:8080/api/v1/admin/meta{"data":{"models":[{"slug":"tasks","singular":"Task","plural":"Tasks","pk":"id",
"fields":[{"name":"id","type":"integer","required":false,"readonly":true},
{"name":"title","type":"string","required":true,"readonly":false},
{"name":"done","type":"boolean","required":false,"readonly":false}],
"list":["title","done"],"search":["title"],
"permissions":{"view":"admin.tasks.view","create":"admin.tasks.create","...":"..."}}]}}# The generic data plane.
curl -s -b jar.txt 'http://127.0.0.1:8080/api/v1/admin/resources/tasks?per_page=5'{"data":[{"id":1,"title":"Write the tutorial","done":false,
"created_at":"2026-08-18T16:40:26Z","updated_at":"2026-08-18T16:40:26Z"}],
"meta":{"page":1,"per_page":5,"total":1}}Each resource gets permission keys defaulting to admin.{slug}.{action} —
admin.tasks.view, admin.tasks.create, and so on. They're enforced on every
admin request, granted directly or through groups, and superusers bypass
all of them.
To give someone read-only access, grant just admin.tasks.view — via a group
if more than one person needs it:
permission, _ := auth.EnsurePermission(ctx, db, "admin.tasks.view", "View tasks")
group, _ := auth.EnsureGroup(ctx, db, "viewers")
_ = auth.GrantPermissionToGroup(ctx, db, &group, &permission)
_ = auth.AddUserToGroup(ctx, db, &user, &group)Unauthenticated requests to the admin API get 401; authenticated-but-
unpermitted get 403.
✅ Checkpoint — your task is listed at /admin/, and a non-superuser
without admin.tasks.view gets 403.
Details: admin.md and ADR-013.
Django's manage.py equivalent:
gombit make command backfill_done --package task--package task writes into your existing feature package
(internal/task/) instead of the default internal/commands/, and
registers the command on your CLI at cmd/gombit, again via go/ast.
The command name is normalized to kebab-case for the CLI regardless of how
you typed it, so backfill_done becomes the runnable backfill-done. Run
it:
go run ./cmd/gombit backfill-doneYour binary keeps the whole framework tree — db, routes, doctor,
createsuperuser — plus your own commands.
✅ Checkpoint — go run ./cmd/gombit --help lists backfill-done.
gombit build --embed--embed runs the frontend build and compiles the assets into the Go binary
with go:embed, giving you one artifact that serves the API, the SPA, and
the admin. A bare gombit build without the flag is refused (exit 1): split
deploy is the default, and embedding is opt-in. For a split deploy, build the
API with go build ./cmd/server and build and host frontend/ separately (a
static host or CDN) — both are supported. See build.md.
Configuration is environment-driven and typed:
gombit config showSecrets — database passwords, Cache.Redis.Password, Auth.JWTSecret — are
redacted in that output.
Before deploying, check the environment:
gombit doctorand the schema chain — models, generated code, migrations, and the database you are about to deploy against — in one command:
gombit db checkIt names the layer that is out of step (a migration not applied, a column
someone added by hand, a model change no migration captures) with the command
that fixes it, and exits non-zero, so CI can run it too (--no-db when the job
has no database). See migrations.md.
Production checklist:
| Setting | Value |
|---|---|
GOMBIT_ENV |
production |
GOMBIT_JWT_SECRET |
strong, unique, not in source control |
GOMBIT_DOCS_ENABLED |
off |
Cookie Secure |
true (config validation requires it in production) |
| Database | run gombit db migrate; never AutoMigrate |
✅ Checkpoint — the built binary serves the API, the SPA, and /admin/ on
one port with no node on the box.
You've used the core subsystems. The reference docs go deeper:
| Topic | Doc |
|---|---|
| All commands and flags | cli.md |
| Configuration | config.md |
| Lifecycle hooks | lifecycle.md |
| Raw Gin routes | router.md |
| Field types and constraints | fields.md |
| DTO and validation conventions | contract.md |
OpenAPI and /docs |
openapi.md |
| Client generation and drift | client.md |
| Migrations | migrations.md |
| Databases | database.md |
| Bearer auth | auth.md |
| Cookie auth and CSRF | auth-cookie.md |
| Admin | admin.md |
| Background jobs | jobs.md |
File storage, uploads, and file / image fields |
storage.md, fields.md |
| Single-binary builds | build.md |
| Everything | docs index |
Architecture rationale lives in the ADRs, and current work is tracked in GitHub issues. The README's roadmap lists the batteries (events, scheduler, mail, gRPC, multi-tenancy, i18n) that are deliberately not here yet.
Something wrong or unclear in this tutorial? That's a docs bug — open an issue.