Skip to content

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 ​

bash
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:

ts
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…):

ts
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:

ts
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:

ts
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):

ts
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.

ts
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:

ts
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?

ts
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:

ts
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:

ts
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:

ts
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:

OptionTypeRequired?DefaultDescription
namestringYes—Plural machine name, e.g. 'projects'.
labelstringNohumanize(name)Display name.
schemaz.ZodObjectYes—Entity schema — determines the table columns.
createSchemaz.ZodObjectNo—Creation schema — determines the form and validation.
updateSchemaz.ZodObjectNocreateSchemaEdit schema.
columnsstring[]Noall fieldsOrdered subset of fields to show in the table.
idFieldstringNo'id'Name of the identifier field.

Resource class ​

MemberSignatureDescription
namestringMachine name.
labelstringDisplay name.
idFieldstringIdentifier 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) => ValidationResultValidates 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 ​

TypeShapeDescription
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.
ResourceConfigsee table aboveResource 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 — renders Resource on screen with plain HTML: DataTable, ResourceForm, and the useList hook consume tableView, formView, and AdminDataSource from this package directly.
  • @basaltkit/admin-shadcn — the same role, but with styled shadcn/ui components (Tailwind CSS). This is the package used by the create-basalt --ui scaffold.
  • @basaltkit/dashboard — uses Resource in its resourceSection to 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 implement AdminDataSource against a real Basalt backend (Fastify/Express/Hono).

Released under the MIT License.