Grid
The grid is the resource’s listing: columns, search, filters, pagination,
actions, and export, all declared on Grid[T]. Every string path is
verified against your model’s parsed schema at boot — a typo fails startup,
not a click.
posts.Grid(func(g *steward.Grid[Post]) {
g.Column("ID").Sortable().Width(60)
g.Column("Title").Limit(40).Sortable()
g.Column("Status").Badge(map[any]steward.BadgeColor{"draft": steward.BadgeSecondary, "published": steward.BadgeGreen})
g.Column("Featured").Switch()
g.Column("Author.Name", "Author")
g.QuickSearch("Title", "Body")
g.DefaultSort("ID", true)
g.PerPage(10, 10, 20, 50, 100)
})Columns
Column(path) returns a *Column[T] whose helpers chain. The path is either a
direct field ("Title") or one hop through a relation ("Author.Name",
preloaded for you). A second argument overrides the heading, which otherwise
comes from the field name.
g.Column("Title") // heading "Title"
g.Column("Author.Name", "Written by")A column with nothing behind it takes a function instead. It cannot be sorted or filtered, because there is no column for SQL to reference:
g.ColumnFunc("words", "Words", func(row *Post) template.HTML {
return template.HTML(fmt.Sprint(len(strings.Fields(row.Body))))
})How the helpers combine
There are two kinds, and knowing which is which explains what happens when you chain several.
Transforms replace the value and pass it on — Using and Limit. They stack
in the order written, and they also apply to CSV export.
Presenters turn a value into HTML and end the chain — Badge, Bool,
Link, Image, Display. The last one wins, so writing two is a mistake rather
than a combination. Presenters do not affect the export, which takes the
transformed value as text.
Badge and Using are the pair worth spelling out, because a status column
usually wants both — the colour of “published” and the word for it. The colour
is looked up on the stored value, so both maps are keyed the same way:
g.Column("Status").Badge(map[any]steward.BadgeColor{1: steward.BadgeGreen}).
Using(map[any]string{1: "Live"})A colour map keyed on Using’s replacement ({"Live": BadgeGreen}) is looked
up too, so either spelling works.
Copyable is the exception to both: it wraps whatever presenter came before, so
it goes last.
g.Column("Status").Using(map[any]string{"1": "Live"}).Badge(colors).Copyable()
// └ transform └ presenter └ wraps the resultIt adds a button beside the cell’s contents, shown on hover and always where
there is no hover to give. The cell itself used to be the click target, which
gave the reader nothing to tell them the value could be copied and took the
click away from a link or a badge already inside it. DetailField.Copyable
is the same affordance on a detail page.
Badge(map[any]BadgeColor)
Renders the value as a badge, coloured by lookup. Keys match the raw value first
and then its fmt form, so an int column can be keyed with 1 or "1".
g.Column("Status").Badge(map[any]steward.BadgeColor{
"draft": steward.BadgeSecondary,
"published": steward.BadgeGreen,
"retracted": steward.BadgeRed,
})Colours: BadgeGreen, BadgeBlue, BadgeAzure, BadgePurple, BadgeOrange,
BadgeYellow, BadgeRed (or BadgeDestructive), BadgeOutline,
BadgeSecondary.
BadgeColor is a string type, not an integer enum, and the difference is
deliberate: a panel with its own palette can still write
steward.BadgeColor("brand-teal") and it compiles. What the type buys is
discovery and a typo caught where the value is known — and
Verify catches the rest, naming the colour it did not
recognise and listing the ones it does.
An unlisted value is not an error: a status added to the database later renders as a plain badge rather than breaking the page. An unlisted colour is, because that is a mistake in the code rather than in the data.
Bool(labels ...string)
Renders a coloured dot followed by the word Yes or No. Truthiness covers the
usual Go zero values, so bool, *bool, int and string columns all work.
Give it two words to say something else — which is what a panel that is not in English needs:
g.Column("Active").Bool("Live", "Paused")Two words or none: one is a mistake Verify reports, since there is no way to
tell which half it is.
Link(func(row *T) string)
Renders the value as an anchor whose href your function computes from the whole
row.
g.Column("Slug").Link(func(p *Post) string {
return "https://example.com/blog/" + p.Slug
})The URL here is yours, so it is used exactly as returned — nothing resolves it. Where the column holds an uploaded file’s path, resolve it yourself:
g.Column("File").Link(func(m *Magazine) string { return app.StorageURL(m.File) })Links open in a new tab (target="_blank" rel="noopener"), which suits
outbound URLs. For moving around inside the panel, the row’s own view action is
the better route — it swaps the page over HTMX instead of reloading.
The cell carries a glyph saying what following it does: a paperclip for a stored file, an arrow for a link that leaves the panel. A link is otherwise indistinguishable from text until it is hovered.
Image(width, height)
Renders the value as a thumbnail, cropped to fill the box (object-fit: cover).
Either dimension may be 0 to leave it unconstrained. An empty value renders
nothing rather than a broken image.
g.Column("Cover").Image(60, 40)A thumbnail is too small to judge anything by, so clicking one opens it full size in the panel’s viewer rather than navigating to the record. The viewer shows images and PDFs inline and offers anything else as a link.
For a cell you build yourself, steward.Preview wraps markup in the same
trigger:
g.ColumnFunc("photo", "Photo", func(r *Row) template.HTML {
url := app.DiskURL("", r.Photo)
return steward.Preview(url, template.HTML(`<img src="`+url+`" width="60"/>`))
})A File/Image form field stores a storage-relative path
(posts/20260101-ab12cd34.jpg), not a URL, so the value resolves through the
configured Storage before it reaches the src —
including percent-escaping, which a filename with a space needs. A value that is
already absolute (http://, https://, /…, data:) is left as it stands, so
a column holding full URLs is unaffected.
The stored path, not the resolved URL, is what the CSV export carries and what
Copyable() copies.
Limit(n)
Truncates to n characters and appends an ellipsis. It counts runes, not bytes,
so it will not cut a multi-byte character in half.
Two things follow from it being a transform rather than a presenter. The full
text is not available on hover — put it in the detail view if it matters. And
the truncation reaches the CSV export too, so a column you truncate for width
exports truncated. If the export should carry the whole value, leave the column
untruncated and constrain it with Width instead.
Using(map[any]string)
Replaces raw values with display text, matching the raw value first and then its
fmt form. Unmapped values pass through untouched. Applies to the export.
g.Column("Kind").Using(map[any]string{0: "Article", 1: "Video"})Tags()
Renders a Tags column — a JSON array of values in one text
column — as chips rather than as the raw array.
g.Column("Keywords").Tags()Values are escaped. A column holding a plain string rather than an array shows as one chip.
Copyable()
Wraps the cell in a copy-to-clipboard affordance.
What gets copied is the value as the transforms left it, not the original —
so Limit(20).Copyable() copies the truncated text, ellipsis included. For an ID
or a token, which is where this helper earns its place, leave the transforms off.
On an Image column it copies the stored path rather than the resolved URL,
which is the value worth having.
Display(func(v any, row *T) template.HTML)
The escape hatch: it receives the value (after transforms) and the typed row, and returns HTML.
Warning
The return type is template.HTML, so nothing is escaped for you. Put any
value that came from a user through template.HTMLEscapeString yourself.
g.Column("Score").Display(func(v any, p *Post) template.HTML {
s := template.HTMLEscapeString(fmt.Sprint(v))
if p.Score > 90 {
return template.HTML("<strong>" + s + "</strong>")
}
return template.HTML(s)
})Behaviour helpers
| Helper | Effect |
|---|---|
Sortable() | makes the header clickable. Cannot cross a relation — see the warning below |
Hide() | off by default, present in the column picker, and still included in exports |
Width(px) | fixes the column width |
Help(s) | a tooltip on the header |
The column picker’s choices persist per grid, so Hide() sets the default rather
than the rule.
Inline editing
Two helpers turn a cell into an editor. Both save through the form’s own
pipeline — one PUT per field, with that field’s rules and the resource’s hooks
applied — so an inline edit cannot bypass a validation the form enforces.
g.Column("Title").Editable() // click → text input, Enter saves
g.Column("Featured").Switch() // toggle saves immediatelyEach requires the resource’s form to declare a field for the same path. Without it there is nothing to validate against and the edit is rejected.
Warning
An inline cell renders the editor and nothing else: transforms and presenters
on that column are skipped. Column("Status").Badge(colors).Editable() shows a
text input, never a badge. Pick one or the other per column.
Quick search
QuickSearch(paths...) enables the search box over the given columns. Bare words
become LIKE %word% across all of them.
Beyond that it parses a small query language, ported from dcat-admin:
| Syntax | Meaning |
|---|---|
foo bar | both terms, LIKE across every searched column |
"foo bar" | one term including the space |
title:hello | that field equals hello |
title:%ell% | that field contains ell |
title:ell% | that field starts with ell |
stars:>10 | comparison — > >= < <= != |
status:(a,b,c) | IN list |
stars:[1,9] | BETWEEN, inclusive |
deleted:NULL | IS NULL |
Field names on the left of a colon are resolved against the schema, so a typo is caught rather than silently matching nothing.
Parsing never fails. Anything it cannot make sense of degrades to a bare term, so a stray bracket searches for a stray bracket instead of erroring — a search box should always return something.
Filters
Filters render a panel above the grid and combine with AND.
g.Filter(func(f *steward.Filters[Post]) {
f.Equal("Status").Select(steward.Options{"draft": "Draft", "published": "Published"})
f.Like("Title")
f.DateRange("CreatedAt", "Created")
})Each constructor takes a path and an optional label:
| Constructor | SQL | Input |
|---|---|---|
Equal(path) | path = value | one text box |
Like(path) | path LIKE %value% | one text box |
Gt(path) | path > value | one text box |
Lt(path) | path < value | one text box |
In(path) | path IN (values) | one text box; pair with Select |
Between(path) | lo ≤ path ≤ hi | two boxes — a numeric range |
Date(path) | path = day | a date picker |
DateRange(path) | from ≤ path ≤ to | one calendar, both ends; Datetime() adds times |
Filtering a date
DateRange covers a date column, with or without times:
f.DateRange("PostDate", "Posted") // whole days
f.DateRange("At", "When").Datetime() // momentsWithout Datetime the bounds round out to whole days, which is what a reader
filtering records wants: “1–31 July” includes everything written on the 31st.
With it, both ends carry a time and the bounds are exact to the minute — for a
log or an audit trail, “between 09:15 and 09:45”. A bound given to the minute
still covers that whole minute, for the same reason a bare date covers the whole
day.
Between is the numeric two-ended filter — a price, a priority, an order —
and nothing else:
f.Between("Price") // two boxes, min and maxImportant
Between(...).Datetime() is gone. It rendered two date-and-time boxes, which
DateRange(...).Datetime() now does in one control that also falls back to
the platform’s own inputs on a touch device. The old spelling is refused at
boot with a message naming the replacement, rather than silently behaving as
a numeric filter on a date column.
And five modifiers:
Select(Options)— renders a dropdown instead of a text box.Optionsis amap[string]stringof stored value to label.SelectFunc(fn)— the same, resolved per request.Selecttakes its map when the resource is registered, so a list read from the database there is built at boot and never refreshed.Datetime()— asks for a time alongside the date: onDateRangeboth ends carry one, on any other filter it becomes a single date-and-time picker. Refused onBetween, which is numeric.Placeholder(s)— hint text for the empty input.Span(n)— how many of the panel’s twelve columns the control takes, the same twelve a form divides into.
Unset, each kind takes what it is worth: a range gets six columns because it holds two controls, a date four, everything else three. That is worth overriding when a particular filter needs the room — flowed left to right instead, a date range on one panel came out at 160px while four dropdowns beside it took 225px each.
g.Filter(func(f *steward.Filters[Post]) {
f.Equal("Status").Select(statuses).Span(2)
f.Equal("CategoryID").SelectFunc(categories).Span(3)
f.DateRange("PostDate", "Posted").Span(6)
f.Like("Title").Span(6)
})Below the small breakpoint every filter takes the whole row, since two controls side by side on a phone are two controls too narrow to use.
Where the panel lives
The panel opens between the toolbar and the rows. A grid with enough filters that this pushes the rows off the screen can put them in a drawer instead, over the page from the right:
g.FilterLayout(steward.FiltersDrawer)Config.FilterLayout sets it for every grid; the call above overrides that for
one. Both layouts render the same controls, and the Filters button opens
whichever it is — the drawer is a native <dialog>, so modality, focus and
inert page content are the browser’s. Inside it there is one column, so spans
collapse to the full width.
An empty filter is skipped, so clearing a box does not turn it into a match against the empty string.
Dates
Every date filter carries the same calendar a form field does, and hands back to the platform’s own picker on a touch device.
DateRange is one control for both ends: click a start, click an end, with
This month, Last 7 days and Last 30 days beside the grid. It submits the
same two parameters Between does — f_{Path} and f_{Path}_to — so a
half-open range works and a URL written by hand behaves the same.
Important
An upper bound written as a bare date means the whole of that day. Left to
compare as written it would be that day’s midnight, so a range labelled
“1–31 July” would drop everything written on the 31st after 00:00:00. This
applies to Between(...).Datetime() as well.
Filtering across a relation
A filter path may point one hop through a relation, for every relationship kind:
g.Filter(func(f *steward.Filters[Post]) {
f.Equal("Author.Name", "Author") // belongs to
f.Equal("Tags.ID", "Tag") // many to many, through the pivot
f.Like("Comments.Body", "Comment") // has many
})QuickSearch accepts the same paths, so a search box can reach an author’s name
or a tag.
These compile to owner IN (SELECT … WHERE …) rather than a join. That matters
for correctness, not just style: joining a has-many or many-to-many relation
multiplies the owner’s rows, which would inflate the listing’s total and repeat
rows inside a page. A subquery constrains without changing the row set, so a post
carrying two matching tags still appears once and the count still agrees with the
page.
Soft deletes on the related table are honoured — filtering by a deleted tag matches nothing, rather than quietly matching its rows.
Warning
Sorting cannot cross a relation. ORDER BY needs the column in the result
set, which means a join, so Sortable() on a relation column and a relation
DefaultSort are rejected at boot. Sort by a column on the model itself — often
the foreign key — or add a denormalized column if the ordering matters.
This fails Verify() at startup rather than at click time. Earlier versions
accepted such a sort and then silently ignored it, so an app that declares one
today will refuse to boot until it is removed.
Actions
Three kinds of custom action, built from the same Action value. The handler
receives the selected ids and returns an Envelope describing what
the client should do next.
publish := steward.NewAction("publish", "Publish",
func(c *steward.Context, ids []string) (*steward.Envelope, error) {
// mutate, then tell the client what to do
return steward.Success("Published.").Refresh(), nil
}).Icon("upload").Confirm("Publish the selected posts?")
g.RowAction(publish) // one per row (ids = that row)
g.BatchAction(publish) // acts on the checked rows
g.ToolAction(publish) // toolbar button, no row contextThe same value can be registered as more than one kind, as above — the only
difference is what ends up in ids.
Builders. Icon(name) takes a Lucide name.
Confirm(message) routes the click through the shared alert dialog first.
Danger() styles the action destructively — the button, the menu item, and the
dialog’s confirm action.
Note
Unlike a resource’s icon, an action’s is not checked at boot. A name that
does not exist renders nothing at all rather than failing, which is easy to
miss — Panel.Icons() lists what is available.
Envelope. Success(msg) and Error(msg) start one; Refresh() reloads the
grid, Redirect(url) navigates, Download(url) starts a download, Alert()
shows the message as an alert rather than a toast.
Buttons or a menu
A row’s actions — view, edit, delete, and any RowAction — render side by side by
default. Collapse them behind one trigger panel-wide:
steward.New(steward.Config{
// ...
GridActions: steward.GridActionsMenu, // default: GridActionsButtons
})or for one grid, which wins over the panel-wide setting:
g.ActionStyle(steward.GridActionsButtons)Buttons are one click and read at a glance, but cost a column’s width per action. A menu is one extra click and a fixed width whatever the action count — worth it once a grid has several actions, or enough columns that the row is already crowded. The pinned actions column works with both.
The menu follows the usual keyboard pattern: focus stays on the trigger, arrow
keys move between items, Enter runs the highlighted one, and Escape closes.
Tab leaves the menu rather than stepping through it.
While it is open the menu is moved to the end of the document, so nothing around the table can cut it off — a row near the bottom opens over the pager rather than losing its last item behind it. It returns to its row on close. Scrolling closes it, since a menu that follows its row while the table moves under the cursor is worse than one that gets out of the way.
Trees, groups, reordering
g.Tree("ParentID") // depth-first hierarchy with collapse carets
g.GroupColumns("Publishing", "Status", "Featured") // grouped header over contiguous columns
g.Reorderable("/posts/_order") // drag-drop row handles, POSTs the new orderTree renders the whole hierarchy at once — searching or filtering falls back to
a flat list, since a match’s ancestors need not match themselves.
GroupColumns spans a heading over columns that must be contiguous in
declaration order — the group renders as one colspan run beginning at the first
of them — and adds a second header row. Both rows stay pinned when the grid
scrolls.
Note
A grid with grouped columns loses its column picker. Hiding a column would
leave a group’s colspan describing a run that is no longer there, so the
picker is dropped rather than allowed to break the header.
Reorderable posts the new order to the URL you give it; that route is yours to
implement.
Pagination & export
PerPage(def, options...) sets the default page size and the selector’s choices,
which are 10 and 10, 20, 50, 100 when you say nothing. Windowed page links
(1 … 18 19 20 … 37) appear automatically.
A per_page outside 1…500 falls back to the default rather than being obeyed,
so a hand-written query string cannot ask the database for the whole table.
CSV export honours the current filters, search, sort, and any row scope from a policy. The Export button offers its three modes — everything that matches, this page only, and the checked rows, which appears with a count once something is checked. The header row uses column labels. As noted under the helpers, transforms apply and presenters do not — the export carries text, not badges.
A large export becomes a job
Past 10,000 matching rows an export stops being a download. The request answers at once, and the account is notified with a link to the file when it is built:
Preparing 102,253 rows in the background — you will be notified when the file is ready.
Below the threshold nothing changes: the file is written inside the request and the browser saves it.
The threshold is the point past which holding a request open stops being safe
rather than merely slow. Every proxy in front of a panel has a write timeout —
60 seconds is a common default, and Cloudflare’s is 100 — and when one fires
mid-stream the browser has already had a 200 and a Content-Disposition, so
it saves the truncated file without a word. Measured on a table of 102,253
articles: 22 MB, and 74 seconds to stream. As a job it takes 3.
The difference is not the process it runs in. A streamed export paged with
OFFSET, which re-reads every row it has already skipped, so the last batch of
a large export costs as much as all of the ones before it. Whole-table exports
now walk by primary key instead — WHERE id > last — which is also why a
background export is ordered by key, not by the column the grid was sorted on.
“Everything past this one” needs the key to be the order. A page export, being
one page, keeps the visible sort.
Rows are written in batches of 1,000 either way, so neither path builds the whole table in memory.
Two knobs, both on Config:
steward.Config{
BackgroundExportRows: 50000, // 0 is the default of 10,000; negative always streams
DisableExportWorker: false, // true: something else must call RunPendingExports
}The panel builds queued exports itself, one at a time, so a generated project
needs no second process for this to work. To move the work out of the web
process, set DisableExportWorker and call it from a worker:
app.Jobs = func(a *steward.Panel, s steward.Scheduler) error {
return s.Add("@every 30s", "exports", func(ctx context.Context) error {
_, err := a.RunPendingExports(ctx)
return err
})
}Claiming a job is a conditional update, so the panel and a worker can both be running without two of them building the same file.
A finished export downloads through the panel, not from a public URL, and only for the account that asked for it — the file holds whatever rows that account’s policies allowed it to read.
For the same reason, check where it is written. Exports go to
Config.ExportDisk, or to DefaultDisk when that
is empty — and a default disk whose directory is served by something other than
the panel would publish the file:
Disks: map[string]steward.Disk{
"media": {Storage: &steward.LocalStorage{Dir: "./storage/uploads"}}, // the web server reads this
"exports": {Storage: &steward.LocalStorage{Dir: "./storage/exports"}}, // nothing else does
},
DefaultDisk: "media",
ExportDisk: "exports",Files are stored under an exports/ prefix on whichever disk that is, so a
shared disk keeps them together rather than loose among the uploads — point the
dedicated disk at the parent directory (./storage, not ./storage/exports) or
you get one nested inside the other.
A misspelled ExportDisk fails at New rather than writing somewhere else.
Nothing deletes old ones for you:
gone, err := app.PruneExports(ctx, 7*24*time.Hour)That removes finished jobs older than the age given and the files they point at.
Switching features off
DisableCreate, DisableDelete, DisableEdit, DisableView,
DisableFilter, DisableExport, DisableRowSelector,
DisablePagination, DisableQuickSearch — each removes the UI and the
behaviour, so DisableDelete also rejects DELETE requests. They are not
cosmetic, and need no policy behind them to be enforced.
Wide grids
A grid with enough columns scrolls sideways inside its own container rather than widening the page. The row’s actions are pinned to the trailing edge so they stay reachable at any scroll position, with a divider that appears only while columns are actually passing beneath them.
The rows scroll vertically inside that same container, and the header row stays put as you go down them — grouped columns pin both of their header rows. The search box, filters and pagination stay put with it: they sit outside the scroll area rather than being pinned inside it, so the pagination is always in reach without any stickiness of its own.
There is only ever one vertical scrollbar. A grid page sizes itself to the window, so the page does not scroll — the rows do. A short grid still hugs its rows rather than stretching to the bottom of the window.
That works by handing a height down a chain: the shell is the viewport, the content pane fills it, the page fills the pane, the card is capped by the page, and the table’s container takes what the card has left. Each link matters, which is worth knowing if you replace a template — break one and the container grows to fit every row instead, which puts the scrollbar back on the page and unpins the header, since a sticky header resolves against its scroll container.
A grid rendered inside your own page, with no such chain, can name a height directly:
.table-container { --steward-table-max-height: calc(100dvh - 24rem); }Otherwise nothing to configure. If a grid is uncomfortably wide, Column.Hide()
keeps a column in the picker but off by default.
JSON
The same grid serves Accept: application/json — filters, search, sort,
and pagination included. See JSON API.