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

schemas/rental/equipment.ts
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:

schemas/rental/customer.ts
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:

schemas/rental/rental.ts
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 declarationDashboard result
z.enum(RentalStatus)a status dropdown
canFilter: truethat 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 + groupClassNametwo fields side by side
ref + refLabela paginated, debounced type-ahead over another module
refFiltersthat 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.


On this page