Steward: Admin Panels in Pure Go

Steward: Admin Panels in Pure Go

Table of Contents

Every internal tool needs an admin panel, and writing one is the least interesting work in software. List view, create form, edit form, detail page, delete confirmation. Then validation. Then permissions. Then the same thing again for the next table.

PHP solved this years ago. Laravel has dcat-admin, Django has its admin, and both let you describe a resource and get a working interface out of it.

Go doesn’t really have an answer. You either hand-write the CRUD, or you bolt a JavaScript SPA onto your API and now you’re maintaining two applications and a build toolchain.

So I built Steward: a server-rendered admin panel framework for Go, borrowing dcat-admin’s shape and rebuilding it around generics.

What it looks like to use

A resource is a struct and a few builder calls:

posts := steward.Register[Post](app).Title("Posts").Icon("news")

posts.Grid(func(g *steward.Grid[Post]) {
    g.Column("Title").Limit(40).Sortable()
    g.Column("Status").Badge(map[any]string{"draft": "secondary", "published": "green"})
    g.QuickSearch("Title", "Body")
})

posts.Form(func(f *steward.Form[Post]) {
    f.Text("Title").Rules("required|max:255")
    f.Markdown("Body")
})

steward.Register[Post](app) on its own already gives you a working list, create, edit, detail, and delete. Everything after that is refinement.

The design decisions I’d defend

Generics, so callbacks never hand you a map

This is the whole reason the project exists in Go rather than anywhere else.

Every builder callback receives your actual model type. Grid[Post], Form[Post], hooks that take *Post. Not map[string]any, not interface{} with a type assertion and a prayer.

If you rename a field, the compiler finds every place that referenced it. That’s the entire value proposition, and it’s the thing a dynamically-typed admin framework structurally cannot offer.

Fail at boot, not on click

Column names are strings. Strings are where typos live.

So Steward runs a Verify() pass at startup that checks every column reference in every grid, form, and filter against the parsed schema. A typo takes the process down immediately with a clear message, rather than producing a 500 the first time someone clicks a sort header in production three weeks later.

I’d rather have a loud startup than a quiet Tuesday.

One binary, and no Node in sight

The UI is Tailwind v4, Basecoat components, and htmx. All of it is compiled ahead of time using the esbuild Go API and the Tailwind standalone binary, committed to the repository, and shipped through go:embed.

There is no Node at build time and none at runtime. You get one binary. Templates stay overridable by dropping a file into your own project.

This choice is doing real work for the kind of deployment I usually deal with: copy a file to a server, run it. No toolchain to install, nothing to keep in sync.

Headless for free

Every resource endpoint serves JSON when asked with Accept: application/json, from the same handlers that render HTML. There’s a _schema endpoint too, and opt-in bearer tokens so scripts and mobile clients authenticate without a cookie or a CSRF round trip.

The REST API isn’t a separate feature. It’s content negotiation on work already being done.

Migrations, not AutoMigrate

GORM’s AutoMigrate is convenient and quietly drifts your schema. Steward ships versioned framework migrations plus a runner for yours, so what’s in the database matches what’s in version control.

What’s in it

RBAC with roles, permissions matched on HTTP paths, and policies enforced at resource, row, and field level, plus a drag-and-drop menu manager administered from the panel itself.

The grid does sortable columns, a quick-search DSL (field:value, >n, %contains%), a filter panel, filters across one-hop relations via subqueries so pagination counts stay correct, windowed pagination, CSV export, batch actions, inline editing, tree grids, a column picker, and drag-to-reorder rows.

Forms cover 22 field kinds, declarative validation with separate create and update rules, typed lifecycle hooks, searchable BelongsTo selects, MultiSelect pivot sync, nested hasMany row forms, dirty-field-only updates, and conditional fields that are refused on submit rather than merely hidden. There’s an icon picker over the full Lucide set, drawn from one cached sprite instead of inlining sixteen hundred SVGs.

Plus dashboard widgets, background workers on a cron in a separate process from the panel, and a scaffolding CLI that can generate a resource from a field spec, a live database, or an existing Go struct.

Honest status

Pre-release, under active development, and the APIs will change. I’d use it for an internal tool today. I wouldn’t tell you to build your company on it this month.

The feature list in the repository marks what works end to end versus what’s still on the roadmap, and I’ve kept that distinction honest rather than aspirational. Sorting by a relation column needs a join and is currently rejected at boot instead of half-working. Pinned columns and tabbed form layouts aren’t there yet.

Why I keep working on it

Mostly because I wanted it to exist. Every Go project I build eventually needs an admin panel, and I got tired of writing the same five handlers.

There’s a secondary reason though. Building a framework forces you to have opinions about the language you’re using, and generics in Go are new enough that the ergonomics aren’t settled. Working out what a good typed builder API feels like has taught me more about Go than any amount of application code would have.

Read the docs or browse the source on GitHub. It’s free and open source, and I’d genuinely like to hear where it breaks.

call to action

Ready to build your next project with me?

I’m ready to help you build, improve, and launch your next project — just drop a message and let’s get started.

Get Started Now