Integrations
Steward’s infrastructure needs are expressed as four small interfaces on
Config. Every one has a zero-dependency default; heavier drivers live in
contrib/ as separate modules so your go.mod only pays for what you use.
Cache
type Cache interface {
Get(ctx context.Context, key string) ([]byte, bool, error)
Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
Delete(ctx context.Context, keys ...string) error
}Default: in-process memory cache. Redis driver:
import "github.com/imfiqhan/steward/contrib/redcache"
Cache: redcache.New(redisClient), // any go-redis v9 UniversalClientWhat Steward caches
Two things, both read on nearly every request and changed rarely:
| Key | Holds | Invalidated by |
|---|---|---|
steward:menu | the menu table, as JSON | any menu edit, drag-reorder, or menu:sync |
steward:setting:<slug> | one settings value | writing that setting; also a 10-minute TTL |
Grid queries, resource listings, and dashboard aggregates are not cached.
They vary by filter, sort, page, and the viewer’s row scope, so caching them
correctly is an application decision — use the Cache yourself from a widget
callback or a custom repository if a particular query needs it.
Panel.Setting(ctx, slug) and Panel.SetSetting(ctx, slug, value) read and write
through the cache, so your own settings get the same treatment.
Important
The default cache is per process. Menu and settings writes invalidate only
the replica that served them, so with several replicas the others keep serving a
stale sidebar until the TTL lapses — and the menu entry has no TTL, so it stays
stale until that process restarts. Configure redcache (or any shared
implementation) for any deployment running more than one instance.
MemoryCache treats a ttl of zero or less as “no expiry”, and holds a copy of
the value rather than the caller’s slice.
Storage
Backs File/Image form fields and serves uploads under
{prefix}/_uploads/.
type Storage interface {
Put(ctx context.Context, name string, r io.Reader, size int64, contentType string) (url string, err error)
Delete(ctx context.Context, name string) error
URL(name string) string
}A backend may also implement the optional half:
type SignedURLStorage interface {
SignedURL(ctx context.Context, name string, ttl time.Duration) (string, error)
}Disks
A panel can name several places to put files, each public or private, and a field picks the one it wants:
steward.New(steward.Config{
UploadDir: "./storage/uploads",
Disks: map[string]steward.Disk{
"media": {}, // private, gated and signed
"public": {Public: true}, // served to anyone, plain URLs
"vault": {Storage: s3, Public: false}, // presigned S3
},
DefaultDisk: "media",
})f.Image("Cover").Disk("public") // a website embeds it
f.File("Contract").Disk("vault") // nobody but the panel hands it outColumn.Disk and DetailField.Disk say which disk a stored path belongs to
when a grid or a detail page turns one back into a URL. A stored value is a
path, not a URL, and carries no record of where it came from — so a field that
names a disk on the way in should name the same one on the way out.
Where the files go. A local disk without a Dir of its own gets
UploadDir/{name}. A panel that declares no disks at all keeps UploadDir
itself, so gaining disks does not mean moving files; once disks are named, every
one of them nests, including the default. Set Dir explicitly to keep an
existing directory:
"media": {Storage: &steward.LocalStorage{Dir: "./storage/uploads"}},Each local disk is served under {prefix}/_uploads/{disk}/, and a signature
covers the disk as well as the file and the expiry — so a link to one disk
cannot be pointed at a file of the same name on another.
Default: local disk at Config.UploadDir (./uploads). S3-compatible
driver (AWS, MinIO, R2):
import "github.com/imfiqhan/steward/contrib/s3store"
store, err := s3store.New(s3store.Config{
Endpoint: "s3.amazonaws.com",
AccessKey: key, SecretKey: secret,
Bucket: "my-uploads",
UseSSL: true,
})Uploads are not public
A stored file is served only to a request carrying a session or a signature. Before this the file server sat outside the panel’s authentication, so knowing a path was enough to read any upload without signing in.
StorageURL returns a signed, expiring link whenever the backend can make
one — LocalStorage signs with Config.SecretKey, and s3store presigns, so
an S3 or MinIO bucket needs no public read policy and no CDN in front of it.
Config.SignedURLTTL sets the lifetime (15 minutes by default).
The signature covers the file’s name and its expiry together, so a link cannot be moved to another file or have its expiry extended without invalidating it.
Config.PublicUploads makes the default disk public, which is the whole
panel when only one disk exists. Naming a public disk and putting the fields
that need it there is better: it lets one panel hold a newsroom’s images and a
contract without treating them the same way.
What gets stored, and turning it back into a URL
A File/Image field stores the path it wrote (posts/20260101-ab12.jpg),
not a URL, so the same rows keep working if the backend changes. Anything that
renders one has to resolve it:
url := app.StorageURL("posts/20260101-ab12.jpg")
// local disk → /app/_uploads/posts/20260101-ab12.jpg?exp=…&sig=…
// s3store → https://bucket.s3.amazonaws.com/posts/…?X-Amz-Signature=…StorageURL percent-escapes each path segment, and returns a value that is
already absolute (http://, https://, /…, data:) untouched — so it is safe
over a column mixing stored paths with external URLs.
The grid’s Image column and the detail view’s Image
and Link fields call it for you. Reach for it directly inside a Display or a
Column.Link function, where the URL is yours to build.
Search
Quick search and the command palette use SQL
LIKE by default. A LIKE '%term%' cannot use an index, so it scans: measured
over 102,253 articles, a query cost 1.5–4.2 seconds, worst when it matched
nothing.
Point them at a real engine instead:
import "github.com/imfiqhan/steward/contrib/meilistore"
search, err := meilistore.New(meilistore.Config{
Host: os.Getenv("MEILI_HOST"),
APIKey: os.Getenv("MEILI_KEY"),
Prefix: "newsroom-", // one Meilisearch can serve several panels
})
admin, _ := steward.New(steward.Config{Searcher: search, ...})and declare what goes in the index:
posts.Searchable("Title", "SubTitle")The order is the priority. A match in the first path outranks a match in the
second, so put the field that identifies a record first. Changing the order means
dropping the index and re-running search:reindex: an engine is told its
ranking when the index is created.
The same 102,253 articles, same queries: 3–12 ms. The query that matched nothing — the slowest before — is now the fastest.
Searcher is an interface, so Meilisearch is one implementation rather than the
mechanism. MemorySearcher ships in the box for tests and small panels.
How a hit becomes a row
The engine returns IDs, and the rows are then read through the repository. That is what keeps filters, sorts, and a policy’s row scope working: a search result is narrowed by the same query the grid would have run anyway, so a row a policy hides cannot arrive through search instead.
The engine’s order is kept: rows come back ranked as it ranked them, not in the grid’s usual sort. Clicking a column to sort takes that back — an order someone asked for beats one they did not — but the grid’s default sort does not, since nobody chose it.
It also sets a limit. At most 1000 hits are taken from the engine for one
query, and a filter applied afterwards narrows within those. A search that
fills the window says so — the pager reads of 1000+ rather than of 1000,
which would be a figure the reader has no reason to doubt. Deep paging through
a very broad query is the case this does not serve.
Keeping the index current
Records are indexed as they are written and removed as they are deleted — on the repository’s write path, not from a form hook, so a record changed by a migration or another writer is indexed too.
That covers everything written after the engine was configured. A table that already has rows needs a backfill, once:
go run . search:reindex # or -batch 2000Without it the engine answers for the newest records and silently omits the
rest, which is worse than not searching at all. Re-run it after changing which
paths are Searchable.
Note
Meilisearch indexes asynchronously and tolerates typos. A record is findable a
moment after it is saved rather than instantly, and riverton will match
Riverton and rivertn alike — both are usually what you want in a panel,
and both surprise a test that asserts otherwise.
Mailer
Setting a mailer enables the password-reset flow (forgot-password link on the login page, one-hour stateless tokens):
Mailer: &steward.SMTPMailer{
Host: "smtp.example.com", Port: 587,
Username: "...", Password: "...",
From: "Steward <no-reply@example.com>",
},Scheduler
steward.Scheduler runs recurring jobs — deliberately in a separate worker
process rather than inside the panel. See
Runtime commands.
Mounting under a router
The Panel is a plain http.Handler; mount it anywhere. A Gin helper
ships in contrib:
import "github.com/imfiqhan/steward/contrib/ginsteward"
r := gin.Default()
ginsteward.Mount(r, app)