Web UI & componentes
O Basalt dá-te duas formas de pôr um ecrã à frente da tua API:
- Páginas HTML autónomas — páginas prontas a usar, sem build (
teamsUiRoutes,billingUiRoutes,apiKeysUiRoutes, o visualizador de auditoria). Sem projeto de frontend, semnpm installno cliente. Ótimas para ecrãs internos/de administração. - Um frontend React a sério — uma app Vite + React que fala com a tua API através de um SDK type-safe, com os componentes
@basaltkit/adminà mão para renderizar tabelas e formulários diretamente a partir dos teus esquemas Zod. É isto que ocreate-basalt --uigera, e é o que este guia cobre.
O scaffold --ui
pnpm create basalt my-app --ui # a auth está ligada por omissão → os fluxos de auth prontos vêm juntosA autenticação está ligada por omissão no scaffold, portanto o --ui dá-te também os ecrãs de auth; passa --no-auth se quiseres o frontend sem eles. Requer pnpm — o frontend web/ é um membro do workspace (o scaffolder muda automaticamente para pnpm se o invocaste com outro gestor).
Além da API, isto gera um frontend web/ ligado de ponta a ponta:
web/
├── vite.config.ts # React + Tailwind 4 (@tailwindcss/vite); proxy de /api → o teu backend (sem CORS)
├── index.html
└── src/
├── api.ts # os teus endpoints descritos uma vez com @basaltkit/sdk
├── App.tsx # a app — com --auth: login, registo, esqueci/repor, um dashboard + MFA
├── main.tsx
└── index.css # Tailwind 4: @import, @source do admin-shadcn, tema shadcn (claro/escuro)- React 19 + Vite 8 + Tailwind CSS 4 para o dev server e o build (o Tailwind é configurado no CSS — sem
tailwind.config.jsnem configuração PostCSS). @basaltkit/admin-shadcn— componentes shadcn/ui autênticos, já com tema.@basaltkit/sdk— o cliente type-safe para a tua API.- O dev server do Vite faz proxy de
/apipara o backend, portanto o browser fala same-origin — sem CORS para configurar.
O web está registado como membro do workspace pnpm (com o nome <your-app>-web), portanto o pnpm install na raiz resolve-o. Corre o backend com pnpm dev (API em :3000), depois o frontend com pnpm --filter my-app-web dev — o Vite serve-o em http://localhost:5180 e faz proxy de /api para o backend.
Com a auth ativada (a omissão), o App.tsx traz os fluxos padrão completos out of the box: iniciar sessão (com um desafio TOTP), registo, esqueci-a-password, repor-password via o link ?token enviado por email, e um dashboard que gere o dois-fatores (enroll → secret/otpauth → activate → recovery codes → disable). Passa --no-auth para gerar o frontend sem eles.
O SDK type-safe
O @basaltkit/sdk é um cliente HTTP sem drift: descreves cada endpoint uma vez com Zod, e cada chamada recebe os tipos de input/output corretos, erros estruturados e refresh automático de token. A sua única dependência é o Zod — é browser-friendly e não arrasta o @basaltkit/core.
// src/api.ts — a única fonte de verdade, partilhável entre cliente e testes
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' } }) // tipado { id, name }
const one = await client.projects.get({ params: { id: created.id } })
const all = await client.projects.list()O cliente espelha a forma do teu objeto api, o TypeScript verifica os argumentos, e a resposta do servidor é validada contra o esquema em runtime — uma incompatibilidade lança CLIENT_RESPONSE_MISMATCH em vez de devolver dados errados em silêncio. Muda um campo no backend e o frontend deixa de compilar, em vez de falhar em produção.
Os parâmetros de path preenchem exatamente um segmento: cada valor é codificado para URL (por isso / não acrescenta segmentos), e um valor em falta, vazio, . ou .. lança CLIENT_INVALID_PARAM antes de qualquer pedido ser enviado — assim um id vindo do utilizador nunca consegue redirecionar uma chamada autenticada para outro endpoint. Um placeholder é um :name no início de um segmento; um : noutro sítio é literal, por isso métodos personalizados como /v1/items:batch ou /items/:id:archive funcionam tal como estão escritos.
Dica: auth & refresh de token
Passa um callback getToken (devolve o access token atual, enviado como Authorization: Bearer) e um callback refresh ao createClient. Num 401 o cliente chama refresh uma vez e repete com o novo token — transparente para quem chama; o refresh devolve o novo token, ou null para desistir. O scaffold --ui liga isto às rotas de auth por ti.
const client = createClient(api, {
baseUrl: '/api',
getToken: () => localStorage.getItem('accessToken') ?? undefined,
refresh: async () => {
/* chama POST /auth/refresh, guarda os novos tokens, devolve o access token ou null */
return null
},
})Painéis de administração a partir dos teus esquemas Zod
Três pacotes empilham-se para que possas trocar o aspeto visual sem reescrever a lógica:
| Pacote | Papel |
|---|---|
@basaltkit/admin | Motor headless — a partir de um esquema Zod, deriva colunas de tabela, campos de formulário e validação. Não renderiza nada. |
@basaltkit/admin-react | Camada React — DataTable, ResourceForm, useList, em HTML simples sem estilo. |
@basaltkit/admin-shadcn | Os mesmos componentes, com estilo shadcn/ui (Tailwind). Props idênticas. O que o --ui usa. |
1. Define o recurso uma vez
// resources.ts — lógica pura, sem 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'], // ordem mostrada na tabela
})A partir disto, o motor deriva os rótulos das colunas, os campos do formulário (com o input certo por tipo — texto, checkbox para booleanos, <select> para enums, número para números), quais os campos obrigatórios, e as regras de validação. Usa o fieldsFromSchema diretamente se quiseres os modelos de campo sem um recurso completo.
Rótulos que quem lê reconhece
Esses rótulos derivados vêm do nome do campo: taxId sai Tax Id, e as opções de um enum saem como os valores que guardas — person, company. Serve para um admin interno em inglês e está errado em todo o resto, incluindo um em inglês onde o nome do campo é o nome que o programador lhe deu.
O fields substitui os dois, com a chave no nome do campo, e cobre a tabela e os dois modos do formulário de uma vez:
export const contactos = defineResource({
name: 'contacts',
schema: ContactSchema,
createSchema: CreateContactSchema,
fields: {
taxId: { label: 'NIF' },
kind: { label: 'Tipo', options: { person: 'Pessoa', company: 'Empresa' } },
},
})Os valores guardados não mudam — o options só troca o que se mostra, portanto o formulário continua a submeter person e o teu schema continua a validá-lo. Uma opção que fique de fora mantém o valor cru, e uma chave que não nomeia campo nenhum é ignorada em vez de estragar o recurso.
O ResourceForm aceita também chooseLabel para o texto de um select por preencher (por omissão Select…), ao lado do submitLabel que já aceitava.
2. Liga uma fonte de dados
O motor lê e escreve através de uma AdminDataSource — { list, get, create, update, remove }. Usa memoryDataSource(seed) para demos, ou apoia-a no teu cliente SDK type-safe para uma API real:
// 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. Renderiza-o
// ProjectsPage.tsx
import { DataTable, ResourceForm } from '@basaltkit/admin-shadcn' // ou @basaltkit/admin-react (sem estilo)
import { useList } from '@basaltkit/admin-react' // o hook vive em admin-react
import { projects } from './resources'
import { projectsSource } from './source'
export function ProjectsPage() {
const { data, loading, error, reload } = useList(projectsSource)
if (loading) return <p>A carregar…</p>
if (error) return <p>Algo correu mal.</p>
return (
<>
<DataTable resource={projects} rows={data} />
<ResourceForm
resource={projects}
onSubmit={async (values) => {
await projectsSource.create(values)
reload()
}}
/>
</>
)
}O useList(source) carrega a lista no mount e devolve { data, loading, error, reload }. O DataTable formata as células (booleanos como Yes/No, datas como 2026-08-07); o ResourceForm renderiza um input por campo com validação por campo e mensagens de erro guiadas pelo teu createSchema — só chama onSubmit com dados válidos. Troca o import de DataTable/ResourceForm entre @basaltkit/admin-react (sem estilo) e @basaltkit/admin-shadcn (com estilo) — as props são idênticas. O hook useList e o helper formatCell são exportados apenas de @basaltkit/admin-react, portanto importa-os de lá independentemente de que skin de componentes uses.
As primitivas shadcn
O @basaltkit/admin-shadcn também exporta as próprias primitivas shadcn — Button, Input, Label, Card, CardHeader, CardContent, CardTitle, Badge, Table — para que construas o resto do teu painel (cabeçalhos, cartões de métricas, ações) com o mesmo aspeto, sem copiar os ficheiros do shadcn para o teu projeto.
Aviso: o Tailwind é obrigatório para o estilo
As classes do @basaltkit/admin-shadcn só produzem cores/espaçamento se a tua app tiver o Tailwind CSS configurado com as variáveis de tema do shadcn (--primary, --border, …) e analisar os ficheiros do pacote. No Tailwind 4 (o que o scaffold gera), regista-o com @source na tua folha de estilos — o caminho é relativo ao ficheiro CSS — e mapeia as variáveis em @theme inline:
/* src/index.css */
@import 'tailwindcss';
@source '../node_modules/@basaltkit/admin-shadcn/dist';
@theme inline {
--color-primary: hsl(var(--primary));
/* … as restantes cores shadcn e --radius-* */
}No Tailwind 3, adiciona antes o pacote ao content:
// tailwind.config.js
content: ['./index.html', './src/**/*.{ts,tsx}', './node_modules/@basaltkit/admin-shadcn/dist/**/*.js']O scaffold --ui faz tudo isto por ti. A integrar à mão? Segue ui.shadcn.com/docs/installation mais a linha @source (ou content) acima.
Dashboards
O @basaltkit/dashboard compõe uma visão geral a partir dos teus dados — defineDashboard com secções de métricas/auditoria/fila, métricas de billing (computeBillingMetrics, churnRate) e resumos de fila — renderizados pelos mesmos componentes shadcn.
Analytics — tendências, não só snapshots
O computeBillingMetrics é um snapshot num instante; o mrrMovement transforma dois snapshots no MRR bridge de SaaS — como a receita mudou, decomposta:
import { mrrMovement, growth } from '@basaltkit/dashboard'
const m = mrrMovement(subsMesPassado, subsEsteMes, plans)
// { new, reactivation, expansion, contraction, churned, net, previousMrr, currentMrr }
// new + reactivation + expansion − contraction − churned === net
const g = growth(metricasMesPassado, metricasEsteMes)
g.mrr // { previous, current, delta, pct } → seta cima/baixo + "+22%" num card de KPIOs snapshots fazem match por billableId, por isso dás-lhe dois subscriptions.all() tirados em momentos diferentes (persiste um snapshot mensal, ou faz diff de um guardado). Tudo é puro e browser-safe.
Branding white-label
A vender o painel a outras empresas? Cada tenant traz o seu próprio nome de produto, logo e cores sobre um brand default:
import { resolveBranding, brandingStyleSheet, defineDashboard } from '@basaltkit/dashboard'
const brand = await resolveBranding(brandingStore, tenantId) // merged sobre o teu default
const dashboard = defineDashboard({ branding: brand, sections })
dashboard.title // o nome de produto do tenant
// injeta as cores do brand como CSS custom properties no <head> do shell:
`<style>${brandingStyleSheet(brand)}</style>` // :root { --brand-primary: … }O resolveBranding faz deep-merge, por isso um tenant que sobrescreve uma cor mantém o resto do teu tema. Nomes e valores são validados e o input inseguro é descartado (o CSS é injetado em <style>), por isso branding vindo do tenant é seguro — mesmo assim serve o shell sob uma CSP que restrinja estilos inline como defesa em profundidade. Emparelha com domínios custom por tenant: resolve o tenant do domínio, depois o brand do tenant.
Que abordagem de UI?
- Páginas HTML autónomas (
admin-pages) — sem projeto de frontend; monta uma rota e abre o URL. Melhor para ecrãs internos de administração (chaves de API, equipa, billing, auditoria). - O frontend React (
--ui) — uma SPA completa com o SDK type-safe e componentes shadcn, para a app que os teus clientes usam.
Compõem-se: uma app React ainda pode embeber ou ligar para as páginas autónomas.