Models
Three zod schemas - and the .meta() keys that decide what every dashboard form looks like.
This is the most important step in the example. Everything the dashboard renders is decided here - the controls, the row layout, which columns can be filtered, and which fields are pickers over another resource.
Each schema comes in two parts, as in Posts CMS: an input schema for what clients may send, and a full schema that adds server-owned fields.
Equipment
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";
export const EquipmentStatus = ["available", "maintenance", "retired"] as const;
export const equipmentInputSchema = z.object({
name: z.string().max(80).meta({ label: "Equipment Name" }),
// enum → dropdown, and filterable on the list page
status: z.enum(EquipmentStatus).default("available").meta({ canFilter: true }),
// `amount` renders a currency-styled number input
dailyRate: z.number().min(0).meta({ fieldHint: "amount", label: "Daily Rate" }),
// `upload` renders the file-table uploader, wired to ImageKit
photo: z.url().optional().meta({ fieldHint: "upload", fileType: "image/*" }),
// max > 100 → textarea, automatically
notes: z.string().max(500).optional(),
});
export const equipmentSchema = equipmentInputSchema.extend({
_id: $objectId.optional(),
tenant: $objectId,
createdAt: z.date().default(() => new Date()),
});
export const equipmentModel = mongodb.db().collection<
z.infer<typeof equipmentSchema>
>("equipment");Customers
Here the group key earns its keep - fname and lname render on one row instead of stacked:
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";
export const customerInputSchema = z.object({
// one row of two: the GROUP's classes go on its FIRST field
fname: z.string().max(50).meta({
group: "fullName",
groupClassName: "grid grid-cols-2 gap-4",
label: "First Name",
}),
lname: z.string().max(50).meta({ group: "fullName", label: "Last Name" }),
email: z.email().meta({ canFilter: true }),
phone: z.e164().optional(), // → international phone input
});
export const customerSchema = customerInputSchema.extend({
_id: $objectId.optional(),
tenant: $objectId,
createdAt: z.date().default(() => new Date()),
});
export const customerModel = mongodb.db().collection<
z.infer<typeof customerSchema>
>("customers");Grouping is by consecutive fields, and the group's classes are read off its first member. Put fname and lname next to each other, and groupClassName on fname only. See the two grouping rules.
Rentals
The interesting one - two ref fields become searchable pickers over the other two resources:
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";
export const RentalStatus = ["booked", "out", "returned", "cancelled"] as const;
export const rentalInputSchema = z.object({
// a picker over the customers module, labelled by first + last name
customer: $objectId.meta({
ref: "customers",
refLabel: ["fname", "lname"],
fieldHint: "autocomplete",
}),
// a picker over equipment, restricted to what is actually available
equipment: $objectId.meta({
ref: "equipment",
refLabel: "name",
refFilters: { status: "available" },
}),
// two dates on one row
startsAt: z.coerce.date().meta({
group: "period",
groupClassName: "grid grid-cols-2 gap-4",
canFilter: true,
}),
endsAt: z.coerce.date().meta({ group: "period" }),
status: z.enum(RentalStatus).default("booked").meta({ canFilter: true }),
totalCents: z.number().min(0).meta({ fieldHint: "amount", label: "Total" }),
});
export const rentalSchema = rentalInputSchema.extend({
_id: $objectId.optional(),
tenant: $objectId,
createdBy: $objectId,
createdAt: z.date().default(() => new Date()),
});
export const rentalModel = mongodb.db().collection<
z.infer<typeof rentalSchema>
>("rentals");What You Just Built, in UI Terms
Without writing a line of frontend code:
| Schema declaration | Dashboard result |
|---|---|
z.enum(RentalStatus) | a status dropdown |
canFilter: true | that status appears in the list page's filter menu |
fieldHint: "amount" | a currency-styled input |
fieldHint: "upload" | an ImageKit file uploader |
z.e164() | an international phone input |
z.string().max(500) | a textarea instead of a single-line input |
group + groupClassName | two fields side by side |
ref + refLabel | a paginated, debounced type-ahead over another module |
refFilters | that type-ahead only offers available equipment |
Server-owned fields - tenant, createdBy, createdAt - are deliberately absent from the input schemas, so the forms never ask for them. The API fills them in next.
Now the routers. On to the API.