Skip to content
Dashboard and custom pages

Dashboard and custom pages

Two things are built the same way: the panel’s home page, and any page of your own. Both are trees of rows, columns and widgets declared in Go — the difference is only where the data comes from. A dashboard tile carries a loader, so a slow aggregate can be deferred; a widget on a custom page takes the value the handler already fetched.

The dashboard

The home page is a list of tiles, each with a data callback and a column span:

app.Dashboard(func(d *steward.Dashboard) {
    d.Metric("Users", func(c *steward.Context) (any, error) {
        var n int64
        return n, c.Panel.DB().Model(&User{}).Count(&n).Error
    }).Span(1).Hint("all time")

    d.Metric("Revenue", monthRevenue).Span(1)

    d.Template("Signups this week", "widgets/signups.html", weeklySignups).
        Span(2).Lazy()
})

Call it before Build. Without it, the built-in overview page is served.

Widgets

Metric(label, load) is a KPI tile. Whatever load returns is stringified, so an int, a string, or a fmt.Stringer all work. Hint adds a line of secondary text under the value, Icon a Lucide glyph in a tinted chip beside the figure, and Color tints the card and that chip:

d.Metric("Users", countUsers).
    Hint("all time").
    Icon("users").
    Color(steward.BadgeBlue)

Color takes the panel’s colour vocabulary, the same values badges use. A glyph that does not exist and a colour outside that set are both boot errors rather than blank squares on the page.

These are the tiles a dashboard declares. A custom page draws from the same widget set, with values instead of loaders.

Template(title, tmpl, load) renders a template of your own, receiving load’s return value as its data. Pass a nil load for a static tile. The template resolves through the normal overlay, so it can live in your project rather than the framework.

Span

Span(n) sets how many of the grid’s three columns a flowed tile occupies, clamped to 1–3. Metrics default to 1, templates to 3. A tile placed in a row takes its column’s width instead, and its own span is ignored. The grid narrows to two columns on tablets and one on phones, and spans clamp with it, so a Span(3) tile never overflows a narrow screen.

Lazy widgets

Lazy() defers the data callback to a follow-up request. The tile renders a skeleton, then fetches {Prefix}/_widget/{index} and replaces itself with the result.

Use it for anything slow. Without it, every callback runs before the page is written, so one expensive aggregate delays the whole dashboard; with it, the page paints immediately and each tile fills in as its query finishes.

The fragment route runs through the same middleware chain as everything else, so authentication applies to it normally.

Route permissions do not — the fragments are exempt, exactly as the dashboard page itself is. Exempting the page but not the tiles it fetches would leave every non-administrator looking at a grid of permission errors, and a tile shows nothing the page does not. A widget’s own callback is the place to gate anything a given role should not see:

d.Metric("Revenue", func(c *steward.Context) (any, error) {
    if !c.User.HasRole("finance") {
        return "—", nil
    }
    return steward.Sum[Order](c, "Total")
})

Charts

A chart tile is a fixed height — clamp(180px, 30vh, 300px) — whatever its span. A chart sized by its width instead grew with the tile, and a full-width one drew a canvas nearly 600px tall, which put a dashboard’s tiles several screens apart. A series with no rows says so rather than drawing empty axes.

Legend: true is drawn inside that height rather than added to it, so a row of tiles stays level and the plot area gives up the room instead — a little for one row of labels, more where many series wrap onto several.

Chart(title, load) draws with Basecoat’s Chart component, so charts inherit the panel’s --chart-1…5 theme colours, external tooltips, and legend styling rather than introducing a second visual language.

d.Chart("Visitors", func(c *steward.Context) (*steward.ChartData, error) {
    return &steward.ChartData{
        Type:   steward.ChartBar,
        Labels: []string{"Jan", "Feb", "Mar"},
        Series: []steward.ChartSeries{
            {Label: "Desktop", Values: []float64{186, 305, 237}},
            {Label: "Mobile", Values: []float64{80, 200, 120}},
        },
        Legend: true,
    }, nil
}).Span(2)

The API is column-oriented on purpose: you give Labels once and one ChartSeries per line or bar set, so nothing in your code deals in map[string]any. Steward converts that to the row-shaped payload the component expects.

Type is ChartBar (the default), ChartLine, ChartPie, ChartDoughnut, or ChartRadar. Stacked stacks bar series on both axes. Per series, Color is any CSS colour and defaults to the next theme palette entry, and Fill shades the area under a line.

A series whose Values length does not match Labels is a bug, so the tile reports it instead of emitting a broken chart.

A chart with no labels or no series is treated differently: that is a state, not a fault — a fresh install or a filtered-out period genuinely has nothing to plot — so the tile shows “No data yet.” and keeps its heading. Only a malformed chart reads as a failure, which means an empty table does not make a dashboard look broken.

The runtime

Nothing to install. Chart.js ships inside the module, with its MIT notice beside it, so a chart draws on a panel built straight from go get.

It is served per page rather than bundled into app.js — Chart.js is larger than the whole UI bundle and most pages have no chart, so it loads only on a dashboard that declares one. Basecoat’s own Chart component is small and rides in app.js instead: it attaches to window.basecoat, and anything loaded from the page body runs before that bundle has made one.

If a tile ever says “Chart runtime not loaded”, the assets being served are not the ones that were built — most often an AssetsFS overlay shadowing dist/. Verify reports the same thing, so a test catches it before anyone opens the dashboard.

Basecoat’s Chart component is documented as a beta API — option names and generated markup may change before its 1.0. Steward pins the shape it sends, so a breaking change there surfaces here as a version bump rather than in your code.

Aggregates

Widgets need counts and sums, so those come with the framework rather than as hand-written SQL in every callback.

d.Metric("Users", func(c *steward.Context) (any, error) {
    return steward.Count[User](c)
})

d.Metric("Revenue", func(c *steward.Context) (any, error) {
    return steward.Sum[Order](c, "Total")
})

d.Chart("Signups", func(c *steward.Context) (*steward.ChartData, error) {
    rows, err := steward.PeriodCount[User](c, "CreatedAt", steward.PeriodMonth)
    if err != nil {
        return nil, err
    }
    return rows.Chart(steward.ChartLine, "Signups"), nil
})

d.Chart("By status", func(c *steward.Context) (*steward.ChartData, error) {
    rows, err := steward.GroupCount[Order](c, "Status", 5)
    if err != nil {
        return nil, err
    }
    return rows.Chart(steward.ChartDoughnut, "Orders"), nil
})
HelperReturns
Count[T](c, conds…)total row count
Sum[T](c, path, conds…)total of a numeric field
GroupCount[T](c, path, limit, conds…)counts per distinct value, largest first
PeriodCount[T](c, path, period, conds…)counts per time bucket, oldest first
PeriodSum[T](c, sumPath, datePath, period, conds…)sums per time bucket
Aggregate[T](c, *AggQuery)anything the above don’t cover

conds are the same Cond values a grid filter uses, so steward.Cond{Path: "Status", Op: steward.OpEq, Val: "paid"} narrows any helper.

AggRows carries the result. .Chart(type, label) turns it straight into a single-series chart and .Total() sums it, so the common paths need no reshaping.

Periods

PeriodDay, PeriodMonth, and PeriodYear bucket a date column. Keys come back as 2026-01-15, 2026-01, and 2026 — formats that sort lexicographically in chronological order, which is why buckets need no re-sorting.

Two things to know. Empty buckets are absent, not zero: the query only sees rows that exist, so fill gaps yourself if the axis must be continuous. And there is no week bucket — the supported engines disagree on where a week starts and on week numbering, so rather than ship three subtly different definitions it is left out for now.

Which repository runs the query

Aggregates go through the registered resource’s Repository[T], so a custom repository’s query refinements and preloads still apply. For a model with no resource, a GORM repository is built on demand — so Count[AuditEntry](c) works whether or not the model is in the panel.

A custom repository that does not implement the optional Aggregator interface reports that plainly rather than silently querying around it. Aggregator is separate from Repository on purpose: folding it in would have broken every existing implementation.

Failures are isolated

If a callback returns an error, that tile renders “Could not load.” and the rest of the page still works — one broken query does not blank the dashboard. The underlying error goes to the logger, not the page, so internal detail is not exposed to whoever is looking at it.

Custom pages

A page of your own is the same tree, rendered by the handler that built it — no template file, and nothing to register:

posts.Page("GET", "report", func(c *steward.Context) error {
    published, drafts := counts(c)

    return c.Layout("Post report",
        steward.Row(
            steward.Col(8, steward.Card("Published over time", steward.Chart(trend))),
            steward.Col(4,
                steward.Metric("Published", published, "live on the site"),
                steward.Metric("Drafts", drafts),
            ),
        ),
        steward.Row(
            steward.Col(12, steward.Card("Most recent",
                steward.Table([]string{"Title", "Status"}, rows))),
        ),
    )
})

No template file, and nothing to register: the handler returns the tree and Layout renders it inside the panel’s chrome.

Rows and columns

A row divides into twelve columns, the same twelve a form divides into, so a span means one thing across the panel. Columns that add up to more than twelve wrap onto the next line.

steward.Row(
    steward.Col(6, left),
    steward.Col(6, right),
)

Below the small breakpoint every column takes the full width. Two columns on a phone are two columns too narrow to read.

A column stacks whatever it is given, and may hold rows of its own:

steward.Row(
    steward.Col(8, steward.Card("Chart", chart)),
    steward.Col(4,
        steward.Row(
            steward.Col(6, steward.Metric("Today", 12)),
            steward.Col(6, steward.Metric("Week", 84)),
        ),
        steward.Card("Notes", steward.Text("…")),
    ),
)

The widget set

Seven, and nothing else is needed for most pages. Each one below shows what it takes and what can be set on it.

Card(title, children…)

The panel’s card. An empty title renders it without a header, which is what you want when the card is a frame rather than a section:

steward.Card("Latest posts", steward.Table(headers, rows))
steward.Card("", steward.Text("A card with no heading."))

A card stacks its children with the same gap a column uses, so several widgets can share one:

steward.Card("This week",
    steward.Metric("Published", 12),
    steward.Divider(),
    steward.Table(headers, rows),
)

Metric(label, value, hint…)

One figure and its label. value is anything printable — an int, a string, a fmt.Stringer. The optional third argument is the dimmer line beneath it.

steward.Metric("Published", 1752)
steward.Metric("Published", 1752, "live on the site")

Two settings chain off it:

Icon(name)a Lucide glyph in a tinted chip beside the figure
Color(c)tints the card and the chip
steward.Metric("Published", 1752, "live on the site").
    Icon("newspaper").
    Color(steward.BadgeGreen)

Color takes the panel’s colour vocabulary — the same values badges use: BadgeGreen, BadgeBlue, BadgeAzure, BadgePurple, BadgeOrange, BadgeYellow, BadgeRed, BadgeSecondary. A glyph that does not exist and a colour outside that set are both boot errors, not blank squares on the page.

The same two methods chain off a dashboard tile:

d.Metric("Users", countUsers).Icon("users").Color(steward.BadgeBlue).Lazy()

Chart(*ChartData)

The chart component, drawn from a series you already have:

steward.Chart(&steward.ChartData{
    Type:   steward.ChartLine,
    Labels: []string{"Jan", "Feb", "Mar"},
    Series: []steward.ChartSeries{{Label: "Posts", Values: []float64{12, 19, 7}}},
})

ChartData carries the settings: Type (ChartLine, ChartBar, ChartDoughnut, ChartPie), Legend, Stacked, and a Color per series. The tile is a fixed height whatever its span, and a series with no rows renders “No data yet.” rather than empty axes.

The aggregate helpers build ChartData for you:

rows, err := steward.PeriodCount[Post](c, "PostDate", steward.PeriodMonth)
if err != nil {
    return err
}
chart := steward.Chart(rows.Chart(steward.ChartLine, "Posts"))

Table(headers, rows)

Rows of values under headers. Cells are escaped, so a value holding markup is shown as text:

steward.Table(
    []string{"Title", "Author"},
    [][]any{
        {"A headline", "Ada"},
        {"Another one", "Grace"},
    },
)

To render a cell — a badge, a link — pass template.HTML for that cell alone:

steward.Table([]string{"Title", "Status"}, [][]any{
    {"A headline", template.HTML(`<span class="badge">Published</span>`)},
})

No rows renders “Nothing to show.” rather than a bare header, and a wide table scrolls inside its own box instead of widening the page.

Text(s) and Heading(s)

Escaped body text and a section heading:

steward.Heading("How this is counted")
steward.Text("Drafts are excluded, and so is anything in the bin.")

Divider()

A rule between sections:

steward.Card("Summary",
    steward.Metric("Published", 1752),
    steward.Divider(),
    steward.Text("Since the site opened."),
)

Markup(template.HTML)

Markup you built yourself — see custom widgets.

Warning

Markup is not sanitized. Pass markup you produced, never a value that came from a request. Everything else on this page escapes what it is given.

Custom widgets

Three routes, in the order worth trying them.

Build the markup

Anything that ends in HTML can be a widget. Write a function that returns a Node and call it like the built-in ones:

// Timeline renders events as a list, in the panel's own type scale.
func Timeline(events []Event) steward.Node {
    var b strings.Builder
    b.WriteString(`<ol class="grid gap-3">`)
    for _, e := range events {
        b.WriteString(`<li class="flex gap-3 text-sm">`)
        b.WriteString(`<span class="text-muted-foreground">` +
            template.HTMLEscapeString(e.At.Format("15:04")) + `</span>`)
        b.WriteString(`<span>` + template.HTMLEscapeString(e.What) + `</span>`)
        b.WriteString(`</li>`)
    }
    b.WriteString(`</ol>`)
    return steward.Markup(template.HTML(b.String()))
}

// used the same way as anything else
steward.Row(
    steward.Col(6, steward.Card("Today", Timeline(events))),
)

Escape every value you interpolate, as above. The panel’s own classes — card, badge, table, text-muted-foreground — are available, so a widget built this way matches the rest without inventing a stylesheet.

Render a template

For anything longer than a few lines, put the markup in a template and let the dashboard render it:

d.Template("Recent activity", "widgets/activity.html", func(c *steward.Context) (any, error) {
    return loadActivity(c)
}).Span(2).Lazy()

The template receives whatever load returns as .Data, and lives in your project’s templates directory — the same overlay that lets you replace any of Steward’s own views. Lazy defers the load to a second request, so a slow query does not hold up the page.

Replace a view

To change a widget everywhere rather than add one, drop a file with the same path into your templates directory: widgets/metric.html replaces the metric tile for the whole panel. See customization.

Why Node is closed

Node cannot be implemented outside the package: its method is unexported. That is deliberate — the renderer walks the tree and must understand every node in it, and an unknown one would have to be rendered as nothing or as an error. Markup is the escape hatch, and it costs nothing: a custom node would have produced markup too.

On the dashboard

The same Row and Col arrange dashboard tiles, which differ from the widgets above in one way: they carry a loader rather than a value, so a slow aggregate can stay Lazy inside a column.

app.Dashboard(func(d *steward.Dashboard) {
    d.Metric("Signed in", countUsers)          // flows into the dashboard grid

    d.Row(                                     // placed explicitly
        steward.Col(8, d.Chart("Trend", trend).Lazy()),
        steward.Col(4,
            d.Metric("This year", countYear).Lazy(),
            d.Metric("This month", countMonth).Lazy(),
        ),
    )
})

Tiles declared without a row flow into the dashboard’s own three-column grid, as they always have. A row stands where it was declared, so what comes before it stays before it.

A tile placed in a row is not also flowed into the grid — the constructor appends it as it is called, and Row takes it out again.

The flowed grid applies its spans through a data-span attribute and plain CSS rather than Tailwind utilities, because the span is chosen at runtime and the shipped bundle carries no col-span classes. If you would rather use utilities, override pages/widgets.html, add the classes to frontend/src/safelist.txt, and rebuild with make assets.