Controlling the UI From the Backend
Your router file and your createCRUD schemas decide the module name, its navigation group, which pages exist, and what each form contains.
This is the part that ties the two projects together, and the part you are most likely looking for.
Thunder UI never guesses at layout. A form's grouping, labels, control types, help text and which columns are filterable are all declared on the backend, as zod .meta() on the schema fields. Those keys mean nothing to the database or to validation - they exist only to shape the generated UI, and they travel to the frontend inside the JSON Schema the SDK carries.
It happens at two levels:
| Level | Decides |
|---|---|
| The router file | the module's name, its navigation group, and which pages exist |
| The schema fields | the form's layout and controls - see Schema Metadata |
A field's plain zod type already decides a lot on its own. .meta() is for everything zod cannot express - see What plain zod already implies.
Route Level: the Router File
A backend route file becomes one module in the UI. Two things on it matter:
import { Router } from "@/core/http/router.ts";
import { createCRUD } from "@/core/utils/createCRUD.ts";
import {
rosterInputSchema,
rosterModel,
rosterSchema,
} from "@/schemas/hr/roster.ts";
import { withTenant } from "@plugins/Huruf-Tech/thunder-core/utils/withTenant.ts";
export default new Router("/api", function rosters(router) {
createCRUD({
router,
schema: rosterSchema, // → list columns, filters, detail
insertSchema: rosterInputSchema, // → the create form
model: rosterModel,
}, {
isolationFields: async (req, action) => {
const { tenant, user } = await withTenant(req);
if (action === "create") {
return { tenant: tenant._id, createdBy: user._id };
}
return { tenant: tenant._id };
},
});
}).group("HR Admin"); // → the UI's navigation groupThe router's name is the module name
function rosters(router) - matching the filename by convention - is the key you use everywhere in the UI:
// src/overrides/crud/cards.tsx
export const cards: TCardsOverride = { rosters: RosterCards }
// src/overrides/routes.tsx
rosters: { merge: true, priority: 0 },
// anywhere
<ListPage name="rosters" />Navigation order is not set here. priority is a frontend-only concern - see Reordering modules and groups.
.group("Label") groups routes in navigation
It is a shared label, reused deliberately across several route files. Every file tagged .group("HR Admin") lands in one HR Admin group in the sidebar, under one parent route:
routes/rosters.ts .group("HR Admin") ─┐
routes/overtimePolicies.ts .group("HR Admin") ─┼─→ /:tenant/hr-admin/...
routes/leavePolicies.ts .group("HR Admin") ─┘The group's URL segment is the label lowercased with whitespace replaced by dashes, so .group("HR Admin") serves at /:tenant/hr-admin/<module>.
A module with no .group() falls into a group literally named Other.
The Three Schemas
createCRUD takes up to three schemas, and the UI uses a different one per surface:
createCRUD option | Used for | UI surface |
|---|---|---|
schema | the full document shape | list columns, the filter menu, and the output intent |
insertSchema | the create payload | the create form (/form) |
updateSchema | the update payload | the edit form (/form/:id) |
Resolution is tolerant, so you only declare what differs:
| Intent | Falls back through |
|---|---|
| create | insertSchema → schema |
| edit | updateSchema → insertSchema → schema |
| list / detail | schema |
So a module with only schema still gets working create and edit forms. Supplying insertSchema keeps server-generated fields (_id, createdAt, tenant) out of the form, and supplying updateSchema lets you narrow what is editable after creation:
createCRUD({
router,
schema: rosterSchema,
insertSchema: rosterInputSchema,
updateSchema: rosterInputSchema.pick({ name: true }), // only the name is editable
model: rosterModel,
});Disabled Operations Remove Pages
The UI generates pages from the endpoints the SDK actually exposes:
- No
createand noupdateon the module → no form route at all. - No
del→ the delete action disappears from the list page's action bar.
So disabling an endpoint on the backend removes the corresponding UI, with nothing to change in the frontend:
createCRUD({ router, schema, model }, { disable: { del: true } });Isolation fields never become inputs
Fields injected by isolationFields - tenant, createdBy and similar - are set server-side and are normally absent from insertSchema, so they never appear as form inputs.
That is the intended arrangement: keep them out of the insert schema and the UI will not ask for them.
None of This Applies Until the SDK Is Rebuilt
The UI reads everything above from the generated SDK. A backend change is invisible to the app until you regenerate:
deno task generate:sdk -v <latest-version> --overwriteSee Two Kinds of Update for which command to run and when.