Skip to content
Integrations

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 UniversalClient

What Steward caches

Two things, both read on nearly every request and changed rarely:

KeyHoldsInvalidated by
steward:menuthe menu table, as JSONany menu edit, drag-reorder, or menu:sync
steward:setting:<slug>one settings valuewriting 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 out

Column.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 2000

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