go-admin turns resource definitions written in Go into a server-rendered admin
panel for SaaS back offices: lists with search, filters, sorting, pagination
and bulk actions, detail pages, validated create and edit forms, confirmed
deletes and a dashboard. It is a plain http.Handler with embedded templates
and no code generation, it never authenticates users itself, it asks your
Authorizer before every page and write, and it ships with a strict
Content-Security-Policy, cross-origin request protection and accessible markup.
Records are read and written through a small generic repository interface,
with an in-memory implementation and a PostgreSQL one on pgx v5.
go get github.com/byteminus/go-admin
Requires Go 1.26 or later.
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/byteminus/go-admin"
"github.com/byteminus/go-admin/pgxrepo"
)
type Customer struct {
ID int64 `admin:"id"`
Name string `admin:"name"`
Email string `admin:"email"`
Plan string `admin:"plan"`
Active bool `admin:"active"`
CreatedAt time.Time `admin:"created_at,readonly"`
Version int64 `admin:"version"`
}
type Staff struct{ Email string }
func (s Staff) DisplayName() string { return s.Email }
// staffFromRequest stands in for your own session lookup.
func staffFromRequest(r *http.Request) (admin.User, bool) {
email := r.Header.Get("X-Staff-Email") // set by your auth proxy or middleware
if email == "" {
return nil, false
}
return Staff{Email: email}, true
}
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, "postgres://localhost:5432/app")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
customers, err := pgxrepo.New[Customer](pool, "customers",
pgxrepo.WithContextTx(), pgxrepo.WithVersionColumn("version"))
if err != nil {
log.Fatal(err)
}
panel, err := admin.New(admin.Config{
Prefix: "/admin",
Title: "Acme back office",
CurrentUser: staffFromRequest,
Authorizer: admin.AllowAll(),
LoginURL: "/login",
Transact: pgxrepo.Transactor(pool),
Widgets: []admin.Widget{
admin.Count("Active customers", "customers", customers,
admin.Condition{Field: "active", Op: admin.OpEq, Value: "true"}),
},
})
if err != nil {
log.Fatal(err)
}
admin.MustRegister(panel, admin.Resource[Customer]{
Name: "customers",
Group: "Accounts",
Repository: customers,
SearchFields: []string{"name", "email"},
DefaultSort: admin.Sort{Field: "created_at", Desc: true},
VersionField: "version",
Title: func(c Customer) string { return c.Name },
Fields: []admin.Field{
{Name: "name", Required: true, Sortable: true},
{Name: "email", Type: admin.FieldEmail, Required: true},
{Name: "plan", Type: admin.FieldSelect, Options: []admin.Choice{
{Value: "free", Label: "Free"},
{Value: "pro", Label: "Pro"},
}},
{Name: "active"},
{Name: "created_at", Type: admin.FieldReadOnly, Show: admin.OnList | admin.OnDetail, Sortable: true},
},
Filters: []admin.Filter{
{Field: "plan", Type: admin.FilterSelect},
{Field: "active", Type: admin.FilterBool},
{Field: "created_at", Type: admin.FilterDateRange},
},
BulkActions: []admin.BulkAction[Customer]{{
Name: "deactivate",
Label: "Deactivate",
Confirm: "Deactivate the selected customers?",
Run: func(ctx context.Context, cs []Customer) error {
for _, c := range cs {
c.Active = false
if err := customers.Update(ctx, &c); err != nil {
return err
}
}
return nil
},
}},
})
mux := http.NewServeMux()
mux.Handle("/admin/", panel)
log.Fatal(http.ListenAndServe(":8080", mux))
}Panel. admin.New(Config) returns an http.Handler. Mount it with
mux.Handle(prefix+"/", panel). Config.CurrentUser and Config.Authorizer
are required; everything else has a working zero value.
Resources. A Resource[T] describes one record type: Name (the URL
segment), labels, IDField (default "id"), DefaultSort, SearchFields,
Fields, Filters, actions, hooks and the Repository. Register it with
admin.Register (or MustRegister); resources referenced by relation fields
are registered first. Registration validates the whole definition against
T and reports every mistake as an error.
Records and tags. T is a struct whose persisted fields carry
admin:"column" tags. Supported field types are strings, booleans, integers,
floats, time.Time, types implementing encoding.TextMarshaler and
TextUnmarshaler (such as uuid.UUID), named types based on these, and
pointers to any of them for nullable columns. The tag option readonly
(admin:"created_at,readonly") marks columns the store manages; they are
never bound from forms, whatever field type they are declared with.
Fields. Each Field has a Label (derived from the column name when
empty), a Type (inferred from the Go type when empty) and a Show
visibility set (OnList, OnDetail, OnForm; zero means all three). Types:
FieldText, FieldTextarea, FieldNumber, FieldBool, FieldSelect (with
Options), FieldDate, FieldDateTime (edited in Config.Location),
FieldEmail, FieldURL, FieldReadOnly and FieldRelation (a select of
another resource's records, named by Relation). Required, MaxLength,
Help, Placeholder and Sortable (for fields shown on the list) complete
the definition.
What a form can write. Only fields declared in Fields, shown on the
form (OnForm), of an editable type and not tagged readonly are bound from
a submission; every other submitted value is ignored, so a crafted POST
cannot set store-managed, computed or undeclared columns. A field kept off
the form gets its value from Resource.New on create and keeps its stored
value on edit. The ID can be entered when creating a record but never changed
afterwards, and the version column is never bound.
Filters. FilterSelect (options from the filter, the field or the related
resource), FilterBool and FilterDateRange (inclusive from and to dates).
Actions. Action[T] runs on one record from its detail page and list row;
BulkAction[T] runs on the rows selected in a list. Both may require a
confirmation page (Confirm) and a permission (default PermUpdate), and run
inside Config.Transact.
Validation and hooks. Submitted values are checked by type (numbers,
dates, emails, absolute http(s) URLs, select options, existing relation
targets), Required and MaxLength, then by Resource.Validate. Errors are
shown next to the inputs and in a summary, with the submitted values kept.
Hooks run before and after create, update and delete; returning
FieldErrors or a PublicError (see admin.Errorf) redisplays the form with
that message, and any other error is logged and answered with a generic 500.
Repository errors wrapping ErrConflict or ErrInvalidRecord are explained
on the form.
Authorization. The Authorizer answers Access{Resource, Permission, Record} questions for PermView, PermCreate, PermUpdate and
PermDelete, on the server, for every request: hiding a button is never the
only check. Every request passes a resource-level check first; record-level
checks follow for viewing (including each list row, which is replaced by a
placeholder when denied), editing (against both the stored and the submitted
record), deleting, running actions, creating once values are bound, and every
record selected for a bulk action (one denied record refuses the whole
action). Edit, delete and action pages also require PermView on the
record, so a write permission never shows more than the detail page would.
Relation selects, relation titles and relation filters only list target
records the user may view.
Row-level scope. A per-record PermView denial hides a list row's data
but not its existence: without more, the row still matches search and
filters, still counts in the total and still takes its place in the sort
order, so a user can recover the searchable, filterable and sortable values
of records they may not view, one guess at a time. SearchFields may also
name columns the list does not display. When some users may list a resource
but not every record in it, set Resource.Scope: it returns conditions for
the current user that the panel adds to every query for that resource
(lists, search, filters, sorting, totals, relation options and titles, bulk
selections and the lookup behind detail, edit, delete and action pages, where
an out-of-scope record is not found). The Authorizer's per-record checks stay
as the final guard. A repository already scoped to the user works too.
admin.Resource[Customer]{
// ...
Scope: func(ctx context.Context) ([]admin.Condition, error) {
u, _ := admin.UserFrom(ctx)
return []admin.Condition{{Field: "account_manager", Op: admin.OpEq, Value: u.DisplayName()}}, nil
},
}Count widgets query their repository directly; give them the same
conditions.
The current user. Config.CurrentUser resolves the user from the
request; the panel then stores it with admin.WithUser, so the Authorizer,
hooks, actions, widgets and repositories read it with admin.UserFrom(ctx).
When your middleware already keeps the user in the context, adapt that lookup
once at startup:
panel, err := admin.New(admin.Config{
CurrentUser: func(r *http.Request) (admin.User, bool) {
u, ok := myapp.UserFrom(r.Context())
return Staff{Email: u.Email}, ok && u.IsStaff
},
Authorizer: admin.AllowAll(),
})Concurrent edits. Without a version, the second of two admins editing the
same record silently overwrites the first, and an edit also writes back the
columns it did not show as they were when the form was submitted. Set
Resource.VersionField to an integer column and enable it on the repository
(memrepo.WithVersionColumn, pgxrepo.WithVersionColumn): the edit form
carries the version the user saw, and a save after someone else's is refused
with a message asking the user to reload, instead of overwriting. pgxrepo
checks and increments the version in the same UPDATE, so the check holds
under concurrency; other writers of the table must increment the column too.
Unauthenticated requests are served by Config.Unauthenticated, or
redirected to Config.LoginURL with the requested path in next (htmx
requests get an HX-Redirect header), or answered with 401.
Security. Responses carry
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'
plus nosniff, DENY framing and a same-origin referrer policy. Templates
contain no inline scripts, styles or event handlers; htmx is configured
through a meta tag with allowEval, indicator styles and history snapshots
turned off. Non-safe requests from other origins are rejected by
http.CrossOriginProtection (it checks Sec-Fetch-Site, then Origin
against Host); Config.TrustedOrigins must be written exactly as browsers
send them, and New rejects anything else. Forms therefore need no CSRF
tokens, and no GET request changes state: deletes and actions show a
confirmation page on GET and run only on POST, with no method override.
Request bodies, URL-encoded or multipart, are capped by Config.MaxFormBytes;
file uploads are refused. Redirects after a write go only to panel paths, and
the login next parameter is always a same-site path. All output goes through
html/template escaping; the only trusted HTML is what custom widgets
return. Stored URL and email values are linked only when they are valid
http(s) URLs and plain addresses; javascript: and data: URLs, and
addresses containing ?, &, % or = (which would add mailto: headers
such as bcc), show as plain text. Notices after redirects are fixed codes, never text taken from the
URL. Every page works without JavaScript.
Dashboard. Config.Widgets are rendered in order. admin.Stat shows a
computed value and admin.Count the number of matching records of a named
resource. A widget with a Resource is shown only to users who may view that
resource and links to its list; a widget without one is shown to everyone who
can open the panel. Custom widgets return trusted template.HTML, so build
it with html/template.
Lists and paging. Lists are paged by number with OFFSET, because people
navigate admin lists by page. Page sizes are capped at MaxPageSize (500) and
page numbers at 100000. On very large tables a deep page still makes the
database read and discard every skipped row, and each page counts all
matching rows; index the sort, filter and search columns and narrow lists
with filters or tenant scoping.
pgxrepo maps a struct onto a table you own; go-admin creates no tables of
its own, so there is no schema to install. A table for the quick start could
be:
CREATE TABLE IF NOT EXISTS customers (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE,
plan text NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro')),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
version bigint NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS customers_created_at_idx ON customers (created_at);pgxrepo.New[T](db, "schema.table", opts...)accepts a*pgxpool.Pool,*pgx.Connorpgx.Tx. Identifiers come only from the struct tags and the table name, are validated against the struct's columns and quoted withpgx.Identifier; all values are bind parameters. Sort and condition fields from a request are checked against the resource definition by the panel and against the struct's columns by the repository, so a craftedsortparameter cannot reach the SQL.- Zero IDs get a UUID v7 for string and
uuid.UUIDkeys; integer keys are left to the column's identity or serial default.readonlycolumns are never written and are read back withRETURNINGafter every write. - Search uses
ILIKEwith%and_escaped. Add apg_trgmindex on searched columns for large tables. - Unique and foreign key violations map to
admin.ErrConflict; NOT NULL, CHECK and data errors toadmin.ErrInvalidRecord. - Times are returned in UTC, with PostgreSQL's microsecond precision (memrepo truncates the same way).
WithVersionColumn("version")makesUpdateconditional on the version and increments it; a stale update fails withadmin.ErrVersionMismatch.
To run each write and its hooks in one transaction, give the panel a
Transact function and let the repositories join the transaction carried in
the context:
repo, err := pgxrepo.New[Customer](pool, "customers", pgxrepo.WithContextTx())
// ...
panel, err := admin.New(admin.Config{
// ...
Transact: pgxrepo.Transactor(pool),
})Inside hooks and actions, other pgx code joins the same transaction through
pgxrepo.TxFrom(ctx). A nested Transactor call runs in a savepoint. When
your application carries its own transaction in the context, pass its lookup
to pgxrepo.WithConn instead:
repo, err := pgxrepo.New[Customer](pool, "customers",
pgxrepo.WithConn(func(ctx context.Context) pgxrepo.Querier {
if tx, ok := myapp.TxFrom(ctx); ok {
return tx
}
return nil // use the pool
}))| Identifier | Purpose |
|---|---|
New(Config) (*Panel, error) |
Create a panel; Panel implements http.Handler. |
Register, MustRegister |
Add a Resource[T] to a panel. |
Panel.URL(resource, id) |
Path of the dashboard, a list or a record. |
Resource[T], Field, FieldType, Visibility, Choice |
Resource definitions. |
Filter, FilterType |
List filters. |
Action[T], BulkAction[T], Hooks[T] |
Operations and write hooks. |
Repository[T], ListQuery, Condition, Op, Sort, ListResult[T] |
Data access. |
ErrNotFound, ErrInvalid, ErrConflict |
Family sentinels, checked with errors.Is. |
ErrInvalidRecord, ErrInvalidQuery, ErrVersionMismatch |
Repository errors wrapping ErrInvalid or ErrConflict. |
FieldErrors, PublicError, Errorf |
Errors shown to users. |
User, UserFunc, Authorizer, AuthorizerFunc, AllowAll, Access, Permission |
Authentication hand-off and authorization. |
WithUser, UserFrom |
The current user in a context. |
Widget, Stat, Count |
Dashboard widgets. |
DefaultPageSize, MaxPageSize, DefaultRelationLimit, DefaultMaxFormBytes, MaxBulkSelection |
Defaults and limits. |
memrepo.New, WithIDColumn, WithUnique, WithVersionColumn |
In-memory repository (not transactional). |
pgxrepo.New, WithIDColumn, WithConn, WithContextTx, WithVersionColumn, Transactor, WithTx, TxFrom |
PostgreSQL repository. |
repotest.Run, repotest.Record, repotest.RunVersioned, repotest.VersionedRecord |
Conformance suites for repositories. |
go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable go test -race -count=1 ./...
Handler tests cover rendering, the CSP header, the validation round trip,
authorization denials and cross-origin rejection with httptest.
PostgreSQL tests create a random schema per test and drop it afterwards, so
they run in parallel against one database; under CI a missing
TEST_DATABASE_URL is a failure, not a skip.
To check your own repository, run the conformance suites against it
(RunVersioned only if it supports version columns):
func TestMyRepository(t *testing.T) {
repotest.Run(t, func(t *testing.T) admin.Repository[repotest.Record] {
return newMyRepository(t) // an empty repository of repotest.Record
})
repotest.RunVersioned(t, func(t *testing.T) admin.Repository[repotest.VersionedRecord] {
return newMyVersionedRepository(t)
})
}- File and image upload fields.
- Inline editing of related records (has-many relations on the parent form).
- CSV and JSON export of lists.
- A searchable relation picker for large target tables (relation selects
currently list the first
RelationLimitrecords). - An audit trail of panel writes.
- Configurable page sizes per request and saved list views.
- Localization of built-in messages.
This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.
MIT; see LICENSE. htmx 2.0.11 is vendored under the Zero-Clause BSD license; see THIRD_PARTY_NOTICES.