Web UI & components
Basalt gives you two ways to put a screen in front of your API:
- Self-contained HTML pages — drop-in, build-free pages (
teamsUiRoutes,billingUiRoutes,apiKeysUiRoutes, the audit viewer). No frontend project, no npm install on the client. Great for internal/admin screens. - A real React frontend — a Vite + React app that talks to your API through a type-safe SDK, with the
@basaltkit/admincomponents on hand to render tables and forms straight from your Zod schemas. This is whatcreate-basalt --uiscaffolds, and what this guide covers.
The --ui scaffold
pnpm create basalt my-app --ui # auth is on by default → the ready-made auth flows come tooAuthentication is on by default in the scaffold, so --ui gives you the auth screens as well; pass --no-auth if you want the frontend without them. Requires pnpm — the web/ frontend is a workspace member (the scaffolder switches to pnpm automatically if you invoked it with another manager).
Alongside the API, this generates a web/ frontend wired end to end:
web/
├── vite.config.ts # React + Tailwind 4 (@tailwindcss/vite); proxies /api → your backend (no CORS)
├── index.html
└── src/
├── api.ts # your endpoints described once with @basaltkit/sdk
├── App.tsx # the app — with --auth: login, register, forgot/reset, a dashboard + MFA
├── main.tsx
└── index.css # Tailwind 4: @import, @source for admin-shadcn, shadcn theme (light/dark)- React 19 + Vite 8 + Tailwind CSS 4 for the dev server and build (Tailwind is configured in CSS — no
tailwind.config.jsor PostCSS config). @basaltkit/admin-shadcn— authentic shadcn/ui components, already themed.@basaltkit/sdk— the type-safe client to your API.- The Vite dev server proxies
/apito the backend, so the browser talks same-origin — no CORS to configure.
web is registered as a pnpm workspace member (named <your-app>-web), so pnpm install at the root resolves it. Run the backend with pnpm dev (API on :3000), then the frontend with pnpm --filter my-app-web dev — Vite serves it on http://localhost:5180 and proxies /api to the backend.
With auth enabled (the default), App.tsx ships the full standard flows out of the box: sign in (with a TOTP challenge), register, forgot-password, reset-password via the emailed ?token link, and a dashboard that manages two-factor (enroll → secret/otpauth → activate → recovery codes → disable). Pass --no-auth to scaffold the frontend without them.
The type-safe SDK
@basaltkit/sdk is a drift-free HTTP client: describe each endpoint once with Zod, and every call gets the right input/output types, structured errors, and automatic token refresh. Its only dependency is Zod — it's browser-friendly and doesn't pull in @basaltkit/core.
// src/api.ts — the single source of truth, shareable between client and tests
import { z } from 'zod'
import { endpoint } from '@basaltkit/sdk'
const Project = z.object({ id: z.string(), name: z.string() })
export const api = {
projects: {
list: endpoint({ method: 'GET', path: '/projects', result: z.array(Project) }),
get: endpoint({ method: 'GET', path: '/projects/:id', params: z.object({ id: z.string() }), result: Project }),
create: endpoint({ method: 'POST', path: '/projects', body: z.object({ name: z.string() }), result: Project }),
},
}import { createClient } from '@basaltkit/sdk'
import { api } from './api.js'
const client = createClient(api, { baseUrl: '/api' })
const created = await client.projects.create({ body: { name: 'Basalt' } }) // typed { id, name }
const one = await client.projects.get({ params: { id: created.id } })
const all = await client.projects.list()The client mirrors the shape of your api object, TypeScript checks the arguments, and the server's response is validated against the schema at runtime — a mismatch throws CLIENT_RESPONSE_MISMATCH instead of silently returning wrong data. Change a field on the backend and the frontend fails to compile, not in production.
Path params fill exactly one segment: each value is URL-encoded (so / cannot add segments), and a missing, empty, . or .. value throws CLIENT_INVALID_PARAM before any request is sent — so a user-supplied id can never redirect an authenticated call to a different endpoint. A placeholder is a :name at the start of a segment; a colon elsewhere is literal, so custom methods like /v1/items:batch or /items/:id:archive work as written.
Auth & token refresh
Pass a getToken callback (returns the current access token, sent as Authorization: Bearer) and a refresh callback to createClient. On a 401 the client calls refresh once and retries with the new token — transparent to the caller; refresh returns the new token, or null to give up. The --ui scaffold wires this to the auth routes for you.
const client = createClient(api, {
baseUrl: '/api',
getToken: () => localStorage.getItem('accessToken') ?? undefined,
refresh: async () => {
/* call POST /auth/refresh, store the new tokens, return the access token or null */
return null
},
})Admin panels from your Zod schemas
Three packages layer up so you can swap the visual look without rewriting logic:
| Package | Role |
|---|---|
@basaltkit/admin | Headless engine — from a Zod schema, derives table columns, form fields, and validation. Renders nothing. |
@basaltkit/admin-react | React layer — DataTable, ResourceForm, useList, in plain unstyled HTML. |
@basaltkit/admin-shadcn | The same components, styled with shadcn/ui (Tailwind). Identical props. What --ui uses. |
1. Define the resource once
// resources.ts — pure logic, no React
import { z } from 'zod'
import { defineResource } from '@basaltkit/admin'
export const projects = defineResource({
name: 'projects',
schema: z.object({
id: z.string(),
name: z.string(),
status: z.enum(['draft', 'published']),
archived: z.boolean().optional(),
}),
createSchema: z.object({
name: z.string().min(3),
status: z.enum(['draft', 'published']),
}),
columns: ['name', 'status'], // order shown in the table
})From this, the engine derives the column labels, the form fields (with the right input per type — text, checkbox for booleans, <select> for enums, number for numbers), which fields are required, and the validation rules. Use fieldsFromSchema directly if you want the field models without a full resource.
Labels the reader recognises
Those derived labels come from the field name: taxId reads Tax Id, and an enum's options come out as the values you store — person, company. That is fine for an internal English admin and wrong everywhere else, including an English one where the field name is the developer's name for the thing.
fields overrides both, keyed by field name, and covers the table and both form modes at once:
export const contacts = defineResource({
name: 'contacts',
schema: ContactSchema,
createSchema: CreateContactSchema,
fields: {
taxId: { label: 'NIF' },
kind: { label: 'Tipo', options: { person: 'Pessoa', company: 'Empresa' } },
},
})The stored values do not change — options only relabels what is shown, so the form still submits person and your schema still validates it. An option left out keeps its raw value, and a key naming no field is ignored rather than failing the resource.
ResourceForm also takes chooseLabel for the placeholder of an empty select (default Select…), alongside the submitLabel it already took.
2. Wire a data source
The engine reads and writes through an AdminDataSource — { list, get, create, update, remove }. Use memoryDataSource(seed) for demos, or back it with your type-safe SDK client for a real API:
// source.ts
import type { AdminDataSource } from '@basaltkit/admin'
import { createClient } from '@basaltkit/sdk'
import { api } from './api'
const client = createClient(api, { baseUrl: '/api' })
export const projectsSource: AdminDataSource = {
list: () => client.projects.list(),
get: (id) => client.projects.get({ params: { id } }),
create: (input) => client.projects.create({ body: input as { name: string } }),
update: (id, input) => client.projects.update({ params: { id }, body: input }),
remove: (id) => client.projects.remove({ params: { id } }).then(() => true),
}3. Render it
// ProjectsPage.tsx
import { DataTable, ResourceForm } from '@basaltkit/admin-shadcn' // or @basaltkit/admin-react (unstyled)
import { useList } from '@basaltkit/admin-react' // the hook lives in admin-react
import { projects } from './resources'
import { projectsSource } from './source'
export function ProjectsPage() {
const { data, loading, error, reload } = useList(projectsSource)
if (loading) return <p>Loading…</p>
if (error) return <p>Something went wrong.</p>
return (
<>
<DataTable resource={projects} rows={data} />
<ResourceForm
resource={projects}
onSubmit={async (values) => {
await projectsSource.create(values)
reload()
}}
/>
</>
)
}useList(source) loads the list on mount and hands back { data, loading, error, reload }. DataTable formats cells (booleans as Yes/No, dates as 2026-08-07); ResourceForm renders one input per field with per-field validation and error messages driven by your createSchema — it only calls onSubmit with valid data. Swap the DataTable/ResourceForm import between @basaltkit/admin-react (unstyled) and @basaltkit/admin-shadcn (styled) — the props are identical. The useList hook and formatCell helper are exported only from @basaltkit/admin-react, so import them from there regardless of which component skin you use.
The shadcn primitives
@basaltkit/admin-shadcn also exports the shadcn primitives themselves — Button, Input, Label, Card, CardHeader, CardContent, CardTitle, Badge, Table — so you build the rest of your panel (headers, metric cards, actions) with the same look, without copying shadcn's files into your project.
Tailwind is required for styling
@basaltkit/admin-shadcn's classes only produce colors/spacing if your app has Tailwind CSS configured with shadcn's theme variables (--primary, --border, …) and scans the package's files. On Tailwind 4 (what the scaffold generates), register it with @source in your stylesheet — the path is relative to the CSS file — and map the variables in @theme inline:
/* src/index.css */
@import 'tailwindcss';
@source '../node_modules/@basaltkit/admin-shadcn/dist';
@theme inline {
--color-primary: hsl(var(--primary));
/* … the other shadcn colors and --radius-* */
}On Tailwind 3, add the package to content instead:
// tailwind.config.js
content: ['./index.html', './src/**/*.{ts,tsx}', './node_modules/@basaltkit/admin-shadcn/dist/**/*.js']The --ui scaffold does all of this for you. Integrating by hand? Follow ui.shadcn.com/docs/installation plus the @source (or content) line above.
Dashboards
@basaltkit/dashboard composes an overview from your data — defineDashboard with metric/audit/queue sections, billing metrics (computeBillingMetrics, churnRate), and queue summaries — rendered by the same shadcn components.
Analytics — trends, not just snapshots
computeBillingMetrics is a point-in-time snapshot; mrrMovement turns two snapshots into the SaaS MRR bridge — how revenue changed, decomposed:
import { mrrMovement, growth } from '@basaltkit/dashboard'
const m = mrrMovement(lastMonthSubs, thisMonthSubs, plans)
// { new, reactivation, expansion, contraction, churned, net, previousMrr, currentMrr }
// new + reactivation + expansion − contraction − churned === net
const g = growth(lastMonthMetrics, thisMonthMetrics)
g.mrr // { previous, current, delta, pct } → an up/down arrow + "+22%" on a KPI cardSnapshots match by billableId, so feed it two subscriptions.all() taken at different times (persist a monthly snapshot, or diff a stored one). Everything is pure and browser-safe.
White-label branding
Selling the panel to other companies? Each tenant ships their own product name, logo and colours over a default brand:
import { resolveBranding, brandingStyleSheet, defineDashboard } from '@basaltkit/dashboard'
const brand = await resolveBranding(brandingStore, tenantId) // merged over your default
const dashboard = defineDashboard({ branding: brand, sections })
dashboard.title // the tenant's product name
// inject the brand's colours as CSS custom properties in the shell <head>:
`<style>${brandingStyleSheet(brand)}</style>` // :root { --brand-primary: … }resolveBranding deep-merges, so a tenant overriding one colour keeps the rest of your theme. Names and values are validated and unsafe input is dropped (the CSS is injected into <style>), so tenant-supplied branding is safe — still serve the shell under a CSP restricting inline styles as defence in depth. Pairs with per-tenant custom domains: resolve the tenant from the domain, then its brand from the tenant.
Which UI approach?
- Self-contained HTML pages (
admin-pages) — no frontend project; mount a route and open the URL. Best for internal admin screens (API keys, team, billing, audit). - The React frontend (
--ui) — a full SPA with the type-safe SDK and shadcn components, for the app your customers use.
They compose: a React app can still embed or link to the self-contained pages.