Package reference
Mirrors the package README (single source). Install @basaltkit/admin v2.0.0 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/admin
Headless engine (no graphical interface) for admin panels: starting from a Zod data schema, it automatically derives table columns, form fields, and validation rules. You need it whenever you want to build an admin panel to manage your application's data — it's the foundation that the @basaltkit/admin-react and @basaltkit/admin-shadcn packages render on screen.
What this module solves
An admin panel is the private area of an application where the team manages data: listing customers, creating projects, editing products, deleting records. Building these screens by hand is repetitive — for each data type you have to write a table, a form, validation, and error messages, almost always with the same structure.
@basaltkit/admin eliminates that repetition. You describe your data once with Zod (a popular TypeScript validation library, where you write e.g. z.string() to say "this field is text"), and the module derives everything else: which columns to show in the table, which fields appear in the form, which are required, and how to validate what the user types.
The word headless means this package doesn't render anything on screen — it has no HTML or visual components. It only produces "view models" (data structures that describe what should appear). What turns that into real screens are the sibling packages: @basaltkit/admin-react (plain HTML) and @basaltkit/admin-shadcn (styled with shadcn/ui). This separation lets you swap the visual look without rewriting the logic.
Installation
pnpm add @basaltkit/admin zod> zod is a peer dependency (a dependency you install yourself, to ensure only one version exists in the project). Zod 4 (^4.0.0) is required.
Get started in 5 minutes
Let's create a "projects" resource and see what the module derives on its own.
Step 1 — Describe your data with Zod. A schema is the description of the shape of the data:
import { z } from 'zod'
// What a project looks like in the database:
const ProjectSchema = z.object({
id: z.string(),
name: z.string(),
status: z.enum(['draft', 'published']), // only these two values are allowed
archived: z.boolean().optional(), // optional
})
// What's needed to CREATE a project (no id — it's generated by the system):
const CreateProjectSchema = z.object({
name: z.string().min(3), // name with at least 3 characters
status: z.enum(['draft', 'published']),
})Step 2 — Define the resource. A resource is an entity the panel manages (projects, customers, products…):
import { defineResource } from '@basaltkit/admin'
const projects = defineResource({
name: 'projects',
schema: ProjectSchema,
createSchema: CreateProjectSchema,
columns: ['name', 'status'], // columns to show in the table, in this order
})Step 3 — See what was derived automatically:
console.log(projects.label)
// 'Projects' (human-readable name, generated from 'projects')
console.log(projects.columns().map((c) => c.label))
// ['Name', 'Status']
console.log(projects.formFields().map((f) => `${f.label} (${f.type})`))
// ['Name (string)', 'Status (enum)']Step 4 — Validate user-entered data:
const ok = projects.validate({ name: 'Basalt', status: 'draft' })
console.log(ok) // { success: true, data: { name: 'Basalt', status: 'draft' } }
const bad = projects.validate({ name: 'ab', status: 'nope' })
console.log(bad.errors)
// { name: 'String must contain at least 3 character(s)', status: '...' }
// One message per field — ready to display under each input.Step 5 — Try it out with in-memory data (no database):
import { memoryDataSource } from '@basaltkit/admin'
const source = memoryDataSource<{ id: string; name: string }>([
{ id: 'p1', name: 'Apollo' },
])
await source.create({ name: 'Nova' }) // generates an id automatically
console.log(await source.list()) // 2 records
console.log(await source.list({ search: 'apo' })) // free-text search → [Apollo]Usage guide
Deriving fields from a schema — fieldsFromSchema
This is the heart of the package: it takes a Zod schema and returns a descriptor for each field (name, readable label, type, required flag, enum options). Wrappers like .optional(), .nullable(), and .default() are "unwrapped" and make the field not required.
import { z } from 'zod'
import { fieldsFromSchema } from '@basaltkit/admin'
const fields = fieldsFromSchema(
z.object({
name: z.string(),
priority: z.number().default(0),
dueAt: z.date().nullable(),
status: z.enum(['draft', 'published']),
}),
)
// [
// { name: 'name', label: 'Name', type: 'string', required: true },
// { name: 'priority', label: 'Priority', type: 'number', required: false },
// { name: 'dueAt', label: 'Due At', type: 'date', required: false },
// { name: 'status', label: 'Status', type: 'enum', required: true,
// options: ['draft', 'published'] },
// ]Readable labels — humanize
Converts technical names into presentable text:
import { humanize } from '@basaltkit/admin'
humanize('createdAt') // 'Created At'
humanize('blog_post_title') // 'Blog Post Title'
humanize('due-date') // 'Due Date'Resources — defineResource and the Resource class
Resource combines the schemas and answers the questions the interface asks: which columns? which fields in the form? is this input valid?
import { z } from 'zod'
import { defineResource } from '@basaltkit/admin'
const tags = defineResource({
name: 'tags',
schema: z.object({ id: z.string(), label: z.string() }),
})
// Without createSchema, the form uses the entity's fields MINUS the id:
tags.formFields().map((f) => f.name) // ['label']
// Without a validation schema, validate accepts anything:
tags.validate({ label: 'x' }) // { success: true, data: { label: 'x' } }Form modes: 'create' uses createSchema; 'update' uses updateSchema (or, if it doesn't exist, createSchema).
Labels and enum options. Every label is derived from the field name through humanize() — taxId reads Tax Id — and an enum's options are the values you store. fields overrides both, keyed by field name, and applies to the table and to both form modes:
const contacts = defineResource({
name: 'contacts',
schema: ContactSchema,
fields: {
taxId: { label: 'NIF' },
kind: { label: 'Tipo', options: { person: 'Pessoa', company: 'Empresa' } },
},
})
contacts.columns().map((c) => c.label) // ['Id', 'NIF', 'Tipo']Labelling never changes what is stored or submitted: field.options still holds ['person', 'company'], and the display text lands in field.optionLabels. Read it through optionLabel(field, value), which falls back to the raw value for an option left unlabelled. A key naming no field is ignored — a rename leaves stale entries behind, and failing the resource over a translation would be a poor trade.
View models — tableView and formView
These functions produce plain objects that a visual layer (React or otherwise) renders:
import { tableView, formView } from '@basaltkit/admin'
const table = tableView(projects, [{ id: 'p1', name: 'A', status: 'draft' }])
// { columns: [...fields...], rows: [...rows...] }
const form = formView(projects, { name: 'A' }, 'create')
// { fields: [...fields...], values: { name: 'A' }, mode: 'create' }Data source — AdminDataSource and memoryDataSource
AdminDataSource is the contract (TypeScript interface) that connects the panel to your real data: five CRUD operations (list, get, create, update, remove). Implement it on top of your API — typically with the @basaltkit/sdk client:
import type { AdminDataSource } from '@basaltkit/admin'
type Project = { id: string; name: string; status: string }
// Example: adapter over any HTTP API
const apiSource: AdminDataSource<Project> = {
async list(params) {
const query = params?.search ? `?search=${encodeURIComponent(params.search)}` : ''
return (await fetch(`/api/projects${query}`)).json()
},
async get(id) {
const res = await fetch(`/api/projects/${id}`)
return res.ok ? res.json() : null
},
async create(input) {
return (await fetch('/api/projects', { method: 'POST', body: JSON.stringify(input) })).json()
},
async update(id, input) {
const res = await fetch(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(input) })
return res.ok ? res.json() : null
},
async remove(id) {
return (await fetch(`/api/projects/${id}`, { method: 'DELETE' })).ok
},
}For tests and prototypes use memoryDataSource(seed) — keeps everything in memory, supports free-text search on string fields, and pagination with { page, pageSize }.
API reference
defineResource(config): Resource
Creates a Resource from a ResourceConfig:
| Option | Type | Required? | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Plural machine name, e.g. 'projects'. |
label | string | No | humanize(name) | Display name. |
schema | z.ZodObject | Yes | — | Entity schema — determines the table columns. |
createSchema | z.ZodObject | No | — | Creation schema — determines the form and validation. |
updateSchema | z.ZodObject | No | createSchema | Edit schema. |
columns | string[] | No | all fields | Ordered subset of fields to show in the table. |
idField | string | No | 'id' | Name of the identifier field. |
Resource class
| Member | Signature | Description |
|---|---|---|
name | string | Machine name. |
label | string | Display name. |
idField | string | Identifier field. |
fields() | () => Field[] | All fields of the entity. |
columns() | () => Field[] | Table columns (respects columns from config). |
formFields(mode?) | (mode?: FormMode) => Field[] | Form fields for the mode ('create' by default); without a schema for the mode, uses the entity's fields minus the id. |
validate(input, mode?) | (input: unknown, mode?: FormMode) => ValidationResult | Validates against the schema for the mode; without a schema, returns success. |
fieldsFromSchema(schema): Field[]
Derives Field[] from a z.ZodObject. Recognized types: ZodString → 'string', ZodNumber → 'number', ZodBoolean → 'boolean', ZodDate → 'date', ZodEnum → 'enum' (with options); anything else → 'unknown'.
humanize(name): string
Converts camelCase, snake_case, and kebab-case to "Title Case" with spaces.
tableView(resource, rows): TableView
Returns { columns: Field[], rows: Record<string, unknown>[] }.
formView(resource, values?, mode?): FormView
Returns { fields: Field[], values, mode }. Defaults: values = {}, mode = 'create'.
memoryDataSource<T>(seed?): AdminDataSource<T>
In-memory data source (T must have id: string). list supports search (free text, string fields only), page, and pageSize. create generates a UUID if the input doesn't include an id. update/get return null when the id doesn't exist; remove returns false.
Exported types
| Type | Shape | Description |
|---|---|---|
Field | { name, label, type, required, options? } | Field/column descriptor. |
FieldType | 'string' | 'number' | 'boolean' | 'date' | 'enum' | 'unknown' | Logical type of the field. |
FormMode | 'create' | 'update' | Form mode. |
ValidationResult | { success, data?, errors? } | errors has one message per field, indexed by name. |
TableView | { columns, rows } | Table view model. |
FormView | { fields, values, mode } | Form view model. |
AdminDataSource<T> | { list, get, create, update, remove } | CRUD contract for the data source. |
ListParams | { page?, pageSize?, search? } | Listing parameters. |
ResourceConfig | see table above | Resource configuration. |
Common errors and solutions (FAQ)
"My fields show up with type 'unknown'." The field uses an unrecognized Zod type (e.g. z.array(), a nested z.object(), z.union()). Only string, number, boolean, date, and enum are classified. For the rest, handle the display in your visual layer.
"The form shows the id field and I don't want it to." This happens when you don't define createSchema. Either define a createSchema without the id, or make sure your identifier field is called id (or adjust idField) — the fallback only removes the idField field.
"validate accepts anything!" Without createSchema/updateSchema there are no rules to apply — validate always returns success. Define an input schema to get validation.
"A field with .default() shows up as not required." This is intentional: if it has a default value, the user isn't required to fill it in.
"memoryDataSource loses data." It really is just memory — restarting the process wipes everything. It's meant for tests and demos; in production implement AdminDataSource on top of your API.
"Error Cannot find module 'zod'." zod is a peer dependency: pnpm add zod.
How it connects to other modules
@basaltkit/admin-react— rendersResourceon screen with plain HTML:DataTable,ResourceForm, and theuseListhook consumetableView,formView, andAdminDataSourcefrom this package directly.@basaltkit/admin-shadcn— the same role, but with styled shadcn/ui components (Tailwind CSS). This is the package used by thecreate-basalt --uiscaffold.@basaltkit/dashboard— usesResourcein itsresourceSectionto build navigation for a full admin panel, and adds billing, queue, and audit metrics.@basaltkit/sdk— Basalt's typed HTTP client; it's the natural way to implementAdminDataSourceagainst a real Basalt backend (Fastify/Express/Hono).