Launch NowDocs

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

PathRole
scripts/generate-resource.tsThe 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:

drizzle/resources/org-projects.json
{
  "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:

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.json

The 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:migrate

See 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

FlagDescription
--spec <path>Path to a JSON spec, relative to the project root. Without it, the script asks questions.
--force, -fOverwrite 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

KeyDefaultDescription
nameRequiredSingular name, converted to camelCase (orgProject). All file, table and function names derive from it.
titleHumanized plural of namePage title and sidebar label.
singularSingular of titleUsed in buttons and toasts ("Add bookmark").
scope"user""user": rows belong to the signed-in user. "organization": rows belong to an organization.
adminOnlyfalseUser scope only. Puts the pages under /admin and the link in the admin sidebar.
pathSee belowRoute of the list page.
labelFieldnameField shown as the link to the detail page. If it doesn't exist, the first text, email or url field is used.
iconBookOpenA lucide-react icon name for the sidebar.
fieldsRequiredAt least one field.

Default paths:

  • User scope: /<name in kebab-case>, singular (bookmark becomes /bookmark), or /admin/<name> with adminOnly. Set path to use a plural URL.
  • Organization scope: /orgs/[orgSlug]/<plural kebab name> (orgProject becomes /orgs/[orgSlug]/org-projects). A custom path is the segment after /orgs/[orgSlug].

adminOnly and "scope": "organization" can't be combined.

Field keys

KeyDescription
nameField name, converted to camelCase. The database column is its snake_case form.
typeOne of the types below. Default text.
labelForm and column label. Defaults to the humanized name (dueDate becomes "Due Date").
requiredAdds .notNull() to the column and makes the field required in the form. Default false.
searchableIncludes the field in the table's search box.
optionsAllowed values of a select field.
defaultValueForm default, and column default for every type except date and json.
minLengthMinimum length for text, textarea and password.
targetResource, targetLabelFieldFor 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

TypeColumnForm inputValidation
texttextInputz.string()
textareatextTextareaz.string()
emailtextEmail inputz.email()
urltextURL inputz.url()
numberintegerNumber inputz.coerce.number()
booleanbooleanCheckboxz.boolean()
datedate (string mode)Date inputz.string()
jsonjsonbTextarea with JSONMust parse as JSON
selecttextNative selectz.enum(options)
passwordtextPassword inputz.string().min(minLength ?? 8)
relationtext foreign keyNative select filled with the target's rowsz.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:

FileContent
drizzle/auth-schema.tsAppends the bookmarks table (SQL name bookmarks) and bookmarksRelations.
lib/bookmark-config.tsbookmarksFields, bookmarksMeta, BOOKMARK_INSERT_SCHEMA, BOOKMARK_UPDATE_SCHEMA, BOOKMARK_ACTION_FIELDS, bookmarksQueryKey.
lib/bookmark-actions.tsServer actions: listBookmarks, getBookmark, createBookmark, updateBookmark, deleteBookmark, deleteBookmarks, plus listCategoriesOptions for the relation select.
features/bookmarks/bookmark-form-sheet.tsxCreate/edit sheet built on ResourceFormSheet.
features/bookmarks/bookmark-table.tsxList built on ResourceTable, loaded with TanStack Query.
app/(app)/bookmarks/page.tsxList page.
app/(app)/bookmarks/[bookmarkId]/page.tsxDetail page with an Edit button.
app/(app)/bookmarks/layout.tsxRedirects signed-out visitors to /auth/signin. User scope, not admin-only.
lib/resource-kit/manifest.jsonAdds or updates the resource entry.
components/layouts/nav-configs/dashboard.tsxAdds 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 call revalidatePath on the list and detail pages.
  • Organization scope: every action takes organizationId, calls requireMembership() and filters by organization. Any member can list, read, create and update; deleting requires the owner or admin role. Mutations call revalidateOrganization().
  • Writes only keep the fields declared in the spec (pickFieldValues()), so a client can't set userId, organizationId or 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 searchable fields (or, if none is marked, every text, textarea, email, url and select field).
  • A faceted filter per select field, 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.ts with 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 title and singular in lib/<name>-config.ts.
  • Validation: the schemas come from buildInsertSchema() and buildUpdateSchema() in lib/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.ts and to the fields array 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.

PathContent
drizzle/auth-schema.tscategories and projects tables. projects.categoryId references categories with on delete set null.
lib/categories-actions.tslistCategories, getCategoryById, createCategory, updateCategory, deleteCategory.
lib/projects-actions.tslistProjects (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 call revalidatePath. Generated resources load and refresh through TanStack Query.
  • Tables: written with the useLegacyTable API of TanStack Table and their own columns, filters and empty states.
  • Forms: dedicated schemas and inputs (NativeSelect for the status and category), wired with useAction and applyValidationErrors().
  • Ownership checks: projects-actions.ts verifies that the selected category belongs to the user (assertCategoryOwned) and throws Project not found when 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.