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:

LevelDecides
The router filethe module's name, its navigation group, and which pages exist
The schema fieldsthe 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:

routes/rosters.ts
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 group

The 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 optionUsed forUI surface
schemathe full document shapelist columns, the filter menu, and the output intent
insertSchemathe create payloadthe create form (/form)
updateSchemathe update payloadthe edit form (/form/:id)

Resolution is tolerant, so you only declare what differs:

IntentFalls back through
createinsertSchema → schema
editupdateSchema → insertSchema → schema
list / detailschema

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 create and no update on 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> --overwrite

See Two Kinds of Update for which command to run and when.


On this page