Skip to content

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 result

It 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

HelperEffect
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 immediately

Each 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:

SyntaxMeaning
foo barboth terms, LIKE across every searched column
"foo bar"one term including the space
title:hellothat field equals hello
title:%ell%that field contains ell
title:ell%that field starts with ell
stars:>10comparison — > >= < <= !=
status:(a,b,c)IN list
stars:[1,9]BETWEEN, inclusive
deleted:NULLIS 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:

ConstructorSQLInput
Equal(path)path = valueone text box
Like(path)path LIKE %value%one text box
Gt(path)path > valueone text box
Lt(path)path < valueone text box
In(path)path IN (values)one text box; pair with Select
Between(path)lo ≤ path ≤ hitwo boxes — a numeric range
Date(path)path = daya date picker
DateRange(path)from ≤ path ≤ toone 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()           // moments

Without 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 max

Important

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. Options is a map[string]string of stored value to label.
  • SelectFunc(fn) — the same, resolved per request. Select takes 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: on DateRange both ends carry one, on any other filter it becomes a single date-and-time picker. Refused on Between, 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 context

The 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 order

Tree 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.