@everystack/admin
v0.4.13
Published
Declarative admin dashboard framework for PostgREST APIs
Readme
@everystack/admin
Declarative admin dashboard framework for PostgREST APIs. Configuration-driven CRUD screens, dashboard widgets, image field renderers, and data hooks — all wired to @everystack/api.
Install
pnpm add @everystack/admin @everystack/api @everystack/ui react react-nativeQuick Start
Define your admin configuration and render with AdminRoot:
import { AdminRoot, EverystackAdapter } from '@everystack/admin';
import { createClient } from '@everystack/api/client';
import type { AdminConfig } from '@everystack/admin';
const client = createClient({ baseUrl: '/api', getToken: () => token });
const adapter = new EverystackAdapter({ client });
const config: AdminConfig = {
resources: {
users: {
label: 'Users',
fields: {
id: { type: 'text', label: 'ID' },
name: { type: 'text', label: 'Name' },
email: { type: 'text', label: 'Email' },
avatar: { type: 'image', label: 'Avatar' },
createdAt: { type: 'datetime', label: 'Created' },
},
listFields: ['name', 'email', 'createdAt'],
searchFields: ['name', 'email'],
},
posts: {
label: 'Posts',
fields: {
id: { type: 'text', label: 'ID' },
title: { type: 'text', label: 'Title' },
content: { type: 'textarea', label: 'Content' },
status: { type: 'select', label: 'Status', options: ['draft', 'published'] },
},
},
},
menu: [
{ label: 'Users', resource: 'users', icon: 'users' },
{ label: 'Posts', resource: 'posts', icon: 'file-text' },
],
};
function AdminApp() {
return <AdminRoot config={config} adapter={adapter} />;
}Entry Points
| Import | Description |
|--------|-------------|
| @everystack/admin | Config types, hooks, screens, adapter, components |
| @everystack/admin/presets/twitter | Pre-built Twitter clone admin preset |
| @everystack/admin/model-map | withModelMap, React-free (tests and server code can import it) |
Admin Configuration
Field Types
| Type | Description |
|------|-------------|
| text | Single-line text input |
| textarea | Multi-line text |
| number | Numeric input |
| boolean | Toggle switch |
| select | Dropdown with options array |
| datetime | Date/time display |
| image | Image upload with ImageFieldInput |
| avatar | Avatar upload with AvatarFieldInput |
| json | JSON display |
| relation | Foreign key reference |
Resource Config
interface ResourceConfig {
label: string;
fields: Record<string, FieldDefinition>;
listFields?: string[]; // Columns shown in list view
searchFields?: string[]; // Fields searchable via text input
defaultSort?: string; // Default ordering (e.g., 'createdAt.desc')
dashboard?: ResourceDashboardConfig; // Per-resource analytics
}List search
With EverystackAdapter (supportsSearch: true), the search box searches listed string,
email and URL fields by substring. Listed number and enum fields match exactly by default.
Typing 42 matches numeric ID 42, not 142. Typing Active matches the configured enum value
active, alongside text fields containing "Active".
Set a field's searchable: false to leave it out. An explicit searchable: true keeps
substring matching, including for numbers and enums. Virtual fields are never searched.
Enum fields need enumValues; automatic defaults only include fields in listFields,
or all fields when listFields is unset. Adapters without supportsSearch: true keep
searching the first field explicitly marked searchable: true.
For direct adapter calls, pass searchFields for substring matches and searchExact
for typed equality in the same OR group. Lists and aggregates accept both:
await adapter.fetchList('users', {
search: '42',
searchFields: ['name'],
searchExact: [
{ field: 'id', kind: 'number' },
{ field: 'status', kind: 'enum', values: ['active', 'archived'] },
],
});Automatic numeric terms accept only whole numbers with at most ten digits and an absolute value at most 2147483647. Decimals and larger values still search text fields. Enum terms use the configured spelling after a case-insensitive match; values containing commas, parentheses, quotes, backslashes or asterisks are skipped. If no configured field can match non-empty text, the adapter returns an empty result without a request. Search text without either field list is an error. RPC-backed lists retain their first-field search; non-empty search text with no first search field returns an empty page without calling the RPC.
Relations from the model map
everystack db:generate writes db/model-map.generated.json from your models (see
docs/model-map.md). Pass your resources through withModelMap and the
admin reads relations from it instead of a hand-written copy:
import { withModelMap, type ModelMap } from '@everystack/admin/model-map';
import modelMap from '../db/model-map.generated.json';
const resources = withModelMap([
{ name: 'profiles', displayField: 'username', fields: [/* ... */] },
{ name: 'posts', fields: [{ name: 'authorId', type: 'relation' }, /* ... */] },
], modelMap as ModelMap);- Relation fields. A field typed
'relation'with norelationblock gets one: the resource that reads the target table, the referenced key, and that resource'sdisplayField. The list then showsaliceinstead of an id, and Edit offers a picker. displayFieldis set once on the target resource. Without it, relations show the id.- Delete preview. A resource with no
associationsgets one per foreign key that points at its primary key. On a hard delete the preview states the database's rule for each ("the database refuses the delete while any exist", "deletes them too", "clears their link"). The admin only shows the rule; the database enforces it. Counts show the rows you can see. A resource whose model islifecycle: truegetslifecycle: truefrom the map: its delete is a soft delete, no rule fires, and the preview says so.lifecyclealso makes Show load a deleted row and offer Restore, which calls the adapter'srestore(POST /<table>/restore). The table's delete rule decides whether the caller may restore. A child table no resource reads is counted by its bare name only when it is public. - Hand-written config wins. A
relationblock or anassociationslist (even[]) is kept. - Left unfilled, showing the id: a target with no un-narrowed resource (a resource with a
wherewould hide rows the key can point at), a target in another schema than the resource reads, a composite or private key, or a field whose name matches no foreign-key column. - Each unfilled relation field says why, once:
withModelMap(resources, map, { onUnfilled })receives{ resource, field, reason, target? }(no-model,no-foreign-key,no-target-resource,no-key). WithoutonUnfilledit is aconsole.warn, except in production. - snake_case naming: a field spelled as the SQL column (
author_id) is matched and gets the SQL key. A one-word column (owner) looks the same in both namings, so it gets the target key as a field key. - A map whose
mapVersionthis admin does not know is refused by number.
Data Adapter
The EverystackAdapter bridges admin hooks to the @everystack/api client:
import { EverystackAdapter } from '@everystack/admin';
import { createClient } from '@everystack/api/client';
const client = createClient({ baseUrl: '/api', getToken: () => token });
const adapter = new EverystackAdapter({
client,
// Optional: custom fetch for RPC calls
rpcFetch: async (name, body) => { /* ... */ },
});The adapter implements the DataAdapter interface:
interface DataAdapter {
list(resource: string, params: ListParams): Promise<ListResult>;
getOne(resource: string, id: string): Promise<DataRecord>;
create(resource: string, data: DataRecord): Promise<DataRecord>;
update(resource: string, id: string, data: DataRecord): Promise<DataRecord>;
delete(resource: string, id: string): Promise<void>;
aggregate(resource: string, params: AggregateParams): Promise<AggregateResult>;
rpc(name: string, body: unknown): Promise<unknown>;
subscribe?(resource: string, callback: (event: SubscriptionEvent) => void): () => void;
}Hooks
useAdminData
CRUD data hook for any resource:
const { data, total, isLoading, error, refetch } = useAdminData('posts', {
page: 1,
perPage: 20,
sort: 'createdAt.desc',
filters: { status: 'eq.published' },
});useAggregates
Server-side aggregation hooks:
const { data: count } = useCount('posts');
const { data: grouped } = useGroupedCount('posts', 'status');
const { data: stats } = useStats('posts', 'views', ['sum', 'avg', 'max']);useRpc
Call server-side RPC functions:
const { data, isLoading, call } = useRpc('dashboard-stats', {
autoFetch: true,
body: { period: '7d' },
});useStorageConfig
Access storage configuration for image uploads:
const { uploadUrl, cdnUrl } = useStorageConfig();Screens
Built-in screens that render from config:
| Component | Description |
|-----------|-------------|
| AdminLayout | Navigation sidebar + content area |
| AdminDashboard | Overview with stat cards |
| ResourceList | Paginated table with filters |
| ResourceShow | Read-only record detail |
| ResourceCreate | Create form |
| ResourceEdit | Edit form |
| ResourceDashboard | Per-resource analytics |
Manual Screen Usage
import { AdminLayout, ResourceList, ResourceEdit } from '@everystack/admin';
// In your router:
<AdminLayout config={config}>
<ResourceList resource="posts" config={config.resources.posts} />
</AdminLayout>Image Fields
Image upload field renderers for use in create/edit forms:
import { ImageFieldInput, AvatarFieldInput, ImageDisplay } from '@everystack/admin';
// In a form
<ImageFieldInput
value={formData.coverImage}
onChange={(key) => setFormData({ ...formData, coverImage: key })}
storageConfig={{ uploadUrl: '/api/storage', cdnUrl: 'https://cdn.example.com' }}
/>
<AvatarFieldInput
value={formData.avatar}
onChange={(key) => setFormData({ ...formData, avatar: key })}
storageConfig={storageConfig}
/>
// Read-only display
<ImageDisplay value={record.coverImage} cdnUrl="https://cdn.example.com" />Dashboard Widgets
SVG-based chart components for analytics:
import { StatDials, DimensionBars, TimeHistogram, EventTable } from '@everystack/admin';
<StatDials data={[{ label: 'Users', value: 1234, trend: 12 }]} />
<DimensionBars data={[{ label: 'iOS', value: 60 }, { label: 'Android', value: 40 }]} />
<TimeHistogram data={timeSeriesData} />
<EventTable columns={columns} data={events} />Presets
Pre-built admin configurations for common schemas:
import { twitterPreset } from '@everystack/admin/presets/twitter';
const config = twitterPreset({
// Override any resource config
resources: {
posts: { label: 'Tweets' },
},
});Utilities
import { toCamelCase, toSnakeCase, resolveImageUrl, uploadFile } from '@everystack/admin';
// Naming conversion
toCamelCase('created_at'); // 'createdAt'
toSnakeCase('createdAt'); // 'created_at'
// Image URL resolution
const url = resolveImageUrl('uploads/abc.jpg', { cdnUrl: 'https://cdn.example.com' });
// File upload
const result = await uploadFile(file, {
uploadUrl: '/api/storage/presign',
getToken: () => token,
});Peer Dependencies
| Package | Version |
|---------|---------|
| @everystack/api | >=0.1.0 |
| @everystack/ui | >=0.1.0 |
| react | >=18.0.0 |
| react-native | >=0.72.0 |
Part of everystack — a self-hosted application stack for Expo apps on AWS.
License
AGPL-3.0-only © Scalable Technology, Inc.
A commercial license is available for organizations that cannot or do not wish to comply with the AGPL-3.0 terms. For commercial licensing, contact [email protected].
