Resource generator
Generate a complete CRUD resource (table, server actions, form, data table, pages and sidebar link) from a JSON spec with pnpm resource:generate.
Most SaaS features start with the same work: a table, create/read/update/delete actions scoped to the user or
the organization, a form, a list and a detail page. pnpm resource:generate writes all of it from a short
JSON spec, using the shared components of the resource kit. You then own the generated code and customize it
like any other file.
The boilerplate also ships two hand-written CRUD examples, categories and projects, that show the same patterns without the generator.
Where the code lives
| Path | Role |
|---|---|
scripts/generate-resource.ts | The CLI behind pnpm resource:generate. |
scripts/templates/ | Code templates: schema.ts, actions.ts, config.ts, form-sheet.tsx, table.tsx, list-page.tsx, detail-page.tsx, layout.tsx, and naming helpers in utils.ts. |
drizzle/resources/ | Resource specs (org-projects.json) and a README.md. |
lib/resource-kit/ | Shared types (types.ts), Zod schema builders (zod.ts), date formatting (format.ts) and manifest.json, the list of generated resources. |
features/resource-kit/ | Shared UI: ResourceTable, ResourceFormSheet, ResourceFieldInput, ResourceDetail, ResourceDeleteAction. |
Quick start
The repository includes the spec of the organization projects example:
{
"name": "orgProject",
"title": "Projects",
"scope": "organization",
"icon": "FolderKanban",
"labelField": "name",
"fields": [
{ "name": "name", "type": "text", "required": true, "searchable": true },
{ "name": "description", "type": "textarea" },
{
"name": "status",
"type": "select",
"options": ["planned", "active", "paused", "done"],
"defaultValue": "planned"
},
{ "name": "dueDate", "type": "date" }
]
}It produced the Projects page of every organization (/orgs/<slug>/org-projects). To create your own
resource:
Write a spec
Create a JSON file in drizzle/resources/, for example drizzle/resources/bookmarks.json:
{
"name": "bookmark",
"title": "Bookmarks",
"path": "/bookmarks",
"icon": "Bookmark",
"labelField": "title",
"fields": [
{ "name": "title", "type": "text", "required": true, "searchable": true },
{ "name": "url", "type": "url", "required": true },
{ "name": "notes", "type": "textarea" },
{ "name": "favorite", "type": "boolean" },
{
"name": "categoryId",
"type": "relation",
"targetResource": "category",
"targetLabelField": "name"
}
]
}Generate the code
pnpm resource:generate --spec drizzle/resources/bookmarks.jsonThe script prints each file it writes, appends the table to drizzle/auth-schema.ts, adds the resource to
the manifest and the sidebar, then formats everything with Prettier.
Create and apply the migration
pnpm db:generate
pnpm db:migrateSee Database for the migration workflow.
Open the page
Start the dev server and click Bookmarks in the sidebar.
Run the command without --spec to answer the same questions interactively: name, title, scope, admin-only,
path, icon, then each field (name, type, required, options or relation target, searchable). An empty field
name ends the list.
Options
| Flag | Description |
|---|---|
--spec <path> | Path to a JSON spec, relative to the project root. Without it, the script asks questions. |
--force, -f | Overwrite generated files that already exist. Without it, existing files are skipped. |
A spec file holds one resource, or several under a resources key:
{ "resources": [{ "name": "author", "fields": ["name"] }, { "name": "book", "fields": ["title"] }] }A field can be written as a plain string, which creates an optional text field.
Spec keys
| Key | Default | Description |
|---|---|---|
name | Required | Singular name, converted to camelCase (orgProject). All file, table and function names derive from it. |
title | Humanized plural of name | Page title and sidebar label. |
singular | Singular of title | Used in buttons and toasts ("Add bookmark"). |
scope | "user" | "user": rows belong to the signed-in user. "organization": rows belong to an organization. |
adminOnly | false | User scope only. Puts the pages under /admin and the link in the admin sidebar. |
path | See below | Route of the list page. |
labelField | name | Field shown as the link to the detail page. If it doesn't exist, the first text, email or url field is used. |
icon | BookOpen | A lucide-react icon name for the sidebar. |
fields | Required | At least one field. |
Default paths:
- User scope:
/<name in kebab-case>, singular (bookmarkbecomes/bookmark), or/admin/<name>withadminOnly. Setpathto use a plural URL. - Organization scope:
/orgs/[orgSlug]/<plural kebab name>(orgProjectbecomes/orgs/[orgSlug]/org-projects). A custompathis the segment after/orgs/[orgSlug].
adminOnly and "scope": "organization" can't be combined.
Field keys
| Key | Description |
|---|---|
name | Field name, converted to camelCase. The database column is its snake_case form. |
type | One of the types below. Default text. |
label | Form and column label. Defaults to the humanized name (dueDate becomes "Due Date"). |
required | Adds .notNull() to the column and makes the field required in the form. Default false. |
searchable | Includes the field in the table's search box. |
options | Allowed values of a select field. |
defaultValue | Form default, and column default for every type except date and json. |
minLength | Minimum length for text, textarea and password. |
targetResource, targetLabelField | For relation fields: the singular name of the target resource and the target field to display (default name). |
The names id, userId, organizationId, createdAt and updatedAt are reserved: those columns are added
automatically.
Field types
| Type | Column | Form input | Validation |
|---|---|---|---|
text | text | Input | z.string() |
textarea | text | Textarea | z.string() |
email | text | Email input | z.email() |
url | text | URL input | z.url() |
number | integer | Number input | z.coerce.number() |
boolean | boolean | Checkbox | z.boolean() |
date | date (string mode) | Date input | z.string() |
json | jsonb | Textarea with JSON | Must parse as JSON |
select | text | Native select | z.enum(options) |
password | text | Password input | z.string().min(minLength ?? 8) |
relation | text foreign key | Native select filled with the target's rows | z.string() |
Optional fields accept empty values, which are saved as null.
A relation field references the target table's id. When the field is required, deleting the target
deletes the row (cascade); otherwise the reference is set to null. The target table must already exist
in drizzle/auth-schema.ts or be generated in the same run. Name relation fields with an Id suffix
(categoryId), so the Drizzle relation gets a clean name (category).
password fields are stored as plain text and returned to the browser by the list
and detail actions; only the UI masks them. Don't use this type for real credentials: hash values in the
generated actions, or store them elsewhere.What gets generated
For the bookmark spec above:
| File | Content |
|---|---|
drizzle/auth-schema.ts | Appends the bookmarks table (SQL name bookmarks) and bookmarksRelations. |
lib/bookmark-config.ts | bookmarksFields, bookmarksMeta, BOOKMARK_INSERT_SCHEMA, BOOKMARK_UPDATE_SCHEMA, BOOKMARK_ACTION_FIELDS, bookmarksQueryKey. |
lib/bookmark-actions.ts | Server actions: listBookmarks, getBookmark, createBookmark, updateBookmark, deleteBookmark, deleteBookmarks, plus listCategoriesOptions for the relation select. |
features/bookmarks/bookmark-form-sheet.tsx | Create/edit sheet built on ResourceFormSheet. |
features/bookmarks/bookmark-table.tsx | List built on ResourceTable, loaded with TanStack Query. |
app/(app)/bookmarks/page.tsx | List page. |
app/(app)/bookmarks/[bookmarkId]/page.tsx | Detail page with an Edit button. |
app/(app)/bookmarks/layout.tsx | Redirects signed-out visitors to /auth/signin. User scope, not admin-only. |
lib/resource-kit/manifest.json | Adds or updates the resource entry. |
components/layouts/nav-configs/dashboard.tsx | Adds a sidebar link. |
The sidebar link goes to dashboard.tsx (user scope), admin.tsx (adminOnly) or organization.tsx
(organization scope, right after "Overview"). Admin pages don't get a layout because
app/(app)/admin/layout.tsx already redirects non-admins; organization pages rely on the
app/(app)/orgs/[orgSlug] layout.
The table
Every generated table has a text id defaulting to randomUUID(), a userId foreign key to user with
cascade delete, createdAt and updatedAt timestamps, and an index on userId. Organization-scoped tables
also get organizationId (cascade delete, indexed). userId then records the creator.
The actions
The generated actions follow the rules of Server actions:
- User scope: every query filters by
ctx.user.id. Mutations callrevalidatePathon the list and detail pages. - Organization scope: every action takes
organizationId, callsrequireMembership()and filters by organization. Any member can list, read, create and update; deleting requires theowneroradminrole. Mutations callrevalidateOrganization(). - Writes only keep the fields declared in the spec (
pickFieldValues()), so a client can't setuserId,organizationIdor timestamps. - Relation values are checked: the target row must belong to the same user or organization, otherwise the action throws "Invalid category".
- Both delete actions use
nonDemoAction, so the shared demo account can't delete data. Bulk delete accepts up to 100 ids.
The UI
ResourceTable renders, from the field list:
- A search box over the
searchablefields (or, if none is marked, every text, textarea, email, url and select field). - A faceted filter per
selectfield, column visibility, sortable columns and pagination. - Row selection with bulk delete, and edit and delete row actions. Both deletes ask for confirmation: the row action through the dialog manager, the bulk delete in an alert dialog.
- Cells formatted by type: badges for booleans and selects, dates without timezone shift, masked passwords.
ResourceFormSheet builds the form with useZodForm() and the generated insert schema, renders one
ResourceFieldInput per field, shows server validation errors inline, and loads relation options when it
opens. ResourceDetail lists every field of a record on the detail page.
Run it again
Running the generator again for the same name:
- Skips existing files, unless you pass
--force, which overwrites them. - Replaces the table definition in
drizzle/auth-schema.tswith the one from the spec. - Updates the manifest entry.
- Leaves the sidebar alone if a link to the same URL exists.
--force overwrites your changes to the generated files. Once you start customizing
a resource, edit its files by hand, and run pnpm db:generate after changing its table.Customize a generated resource
The generated code is yours. Common changes:
- Labels and page text: edit the page files, or
titleandsingularinlib/<name>-config.ts. - Validation: the schemas come from
buildInsertSchema()andbuildUpdateSchema()inlib/resource-kit/zod.ts. Replace them in the config file with your own Zod schemas for rules the spec can't express. - Business rules: add checks, notifications or side effects in
lib/<name>-actions.ts. - Table columns and actions: pass props to
ResourceTable(emptyTitle,emptyDescription,canDelete,renderCreate), or copy it into your feature folder when you need a very different table. - New columns later: add them to
drizzle/auth-schema.tsand to thefieldsarray in the config file, then generate a migration.
Reference implementation: categories and projects
Categories and projects are a hand-written, user-scoped CRUD example. Use them when you want full control over the UI instead of the resource kit.
| Path | Content |
|---|---|
drizzle/auth-schema.ts | categories and projects tables. projects.categoryId references categories with on delete set null. |
lib/categories-actions.ts | listCategories, getCategoryById, createCategory, updateCategory, deleteCategory. |
lib/projects-actions.ts | listProjects (joined with the category name), getProjectById, createProject, updateProject, deleteProject, listCategoriesForSelect. |
features/categories/ | categories-table.tsx, category-form-sheet.tsx. |
features/projects/ | projects-table.tsx, project-form-sheet.tsx. |
app/(app)/categories/, app/(app)/projects/ | List and detail pages. |
Differences with generated resources:
- Data loading: pages fetch the list on the server and pass it as initial props
(
await listCategories()then<CategoriesTable initialCategories={...} />); mutations callrevalidatePath. Generated resources load and refresh through TanStack Query. - Tables: written with the
useLegacyTableAPI of TanStack Table and their own columns, filters and empty states. - Forms: dedicated schemas and inputs (
NativeSelectfor the status and category), wired withuseActionandapplyValidationErrors(). - Ownership checks:
projects-actions.tsverifies that the selected category belongs to the user (assertCategoryOwned) and throwsProject not foundwhen a row doesn't match. See Server actions for the pattern.
Remove the examples
Categories, projects, organization projects and the demo page are examples. pnpm reset-project moves
them to examples/ (kept as reference and excluded from the build), removes the code between
@reset-remove:start and @reset-remove:end markers (tables, sidebar links) and clears the manifest. Run
pnpm reset-project --dry-run first to see what it would change, then generate a migration to drop the
example tables.