Skip to content

CRUD Generators

CRUD generation in JSKIT is really one workflow split across two generator packages:

  • crud-server-generator creates the app-local server/resource package
  • crud-ui-generator creates the route tree that uses that shared resource contract

So the real order is always:

  1. create a real table
  2. scaffold the server package around it
  3. scaffold the UI around the generated resource file

This chapter uses three examples, in increasing complexity:

  • contacts: the baseline full CRUD walkthrough
  • addresses: a child CRUD with its own routed list page reached from the parent record view
  • comments: a child CRUD that lives under the parent record host as a child page

The examples assume the guide app already has the database and workspace/admin setup from the earlier chapters. That is why the route roots below live under w/[workspaceSlug]/admin/....

This is the workflow chapter.

It is meant to answer:

  • which generator do I run first?
  • what order do the server and UI steps go in?
  • what shape of CRUD should I generate for this feature?

Once that workflow is clear, continue with Advanced CRUDs for the generated package anatomy, ownership model, and customization boundaries.

The two generator packages

crud-server-generator @jskit-ai/crud-server-generator (0.1.47)

This is the server-side half.

It introspects a real table and writes an app-local package with things like:

  • repository logic
  • service logic
  • route registration
  • action definitions
  • the shared resource module that describes the CRUD contract

The main subcommand is:

  • scaffold

There is also:

  • scaffold-field

which is for patching one extra field into an already-generated resource module after a schema change.

crud-ui-generator @jskit-ai/crud-ui-generator (0.1.22)

This is the UI half.

It reads the generated shared resource module and creates app-owned pages under an explicit route root relative to src/pages/.

The main subcommand is:

  • crud

That command can generate any combination of:

  • list
  • view
  • new
  • edit

That flexibility is what makes the later comments example possible.

Understanding ownership filters

--ownership-filter is one of the most important CRUD generator options.

It is not just "one more flag." It tells JSKIT what kind of ownership the generated CRUD assumes:

  • whether records are shared or owner-scoped
  • which owner columns the CRUD expects
  • what request context the generated routes and repository use to filter records

In plain English, it answers:

  • who should be able to see these rows?
  • what kind of owner does each row belong to?

The supported values are:

ValuePlain meaningTypical owner columns
autoinfer ownership from the real table during generationinferred from table shape
publicno owner scopingno workspace_id or user_id required
userrows belong to one useruser_id
workspacerows belong to one workspaceworkspace_id
workspace_userrows belong to one workspace and one user togetherworkspace_id and user_id

What each value means

public

Choose public when the same records should be visible across the app, subject only to the usual route/action permissions.

This is the right mental model for things like:

  • global lookup tables
  • app-wide reference data
  • shared configuration records that are not owned by one user or one workspace

A public CRUD does not expect owner scoping columns.

user

Choose user when each record belongs to one user, regardless of which app surface is showing it.

This is the right mental model for things like:

  • my own saved items
  • my saved views
  • my personal preferences records when they are stored as CRUD rows

The important owner column is:

  • user_id

workspace

Choose workspace when records belong to the workspace as a whole.

This is the most common choice for workspace-admin CRUDs such as:

  • contacts
  • appointments
  • products
  • workspace-wide CRM records

The important owner column is:

  • workspace_id

This is why the chapter's contacts example uses workspace: the records belong to the workspace, not to one specific member inside it.

workspace_user

Choose workspace_user when a record belongs to a workspace and to a specific user within that workspace.

This is the right mental model for things like:

  • personal notes inside a workspace
  • per-user workspace drafts
  • user-owned workspace artifacts that should not be visible to every workspace member

The important owner columns are:

  • workspace_id
  • user_id

This is stricter than plain workspace. The row is not only "inside this workspace." It is "inside this workspace, for this user."

auto

auto is not a runtime mode. It is a generation-time inference.

When you scaffold against a real table, the generator inspects the table columns and resolves auto like this:

  • workspace_id and user_id present -> workspace_user
  • workspace_id only -> workspace
  • user_id only -> user
  • neither present -> public

So auto is useful when the table already exists and its ownership columns already tell the truth.

If you already know the intended ownership model and want the command to fail when the table does not match it, choose the explicit value instead of auto.

Which one should I choose?

Use this rule of thumb:

  • choose public for shared reference/config data
  • choose user for "my own" records
  • choose workspace for workspace-wide business data
  • choose workspace_user for personal records that still live inside a workspace
  • choose auto when you are scaffolding from an existing table and want JSKIT to infer ownership from the owner columns already present

Another useful way to think about it is this:

  • if two different users in the same workspace should normally see the same row, that is probably workspace
  • if they should not normally see the same row, that is probably workspace_user
  • if the row is not workspace-specific at all, it is probably public or user

One more important constraint

Ownership also has to make sense for the target surface.

In practice:

  • workspace and workspace_user belong on workspace-enabled surfaces
  • public and user can live on non-workspace surfaces too

So if you are generating under a route like:

text
w/[workspaceSlug]/admin/...

then workspace is usually the normal default. If you are generating a CRUD for a global operator or account area, public or user may make more sense.

Example 1: contacts

This is the baseline pattern. If you understand this example, the rest of the chapter becomes much easier.

Step 1: create the table first

Start with a real table. The server generator reads the database schema; it does not invent it for you.

Example:

sql
CREATE TABLE contacts (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  workspace_id BIGINT UNSIGNED NOT NULL,
  full_name VARCHAR(190) NOT NULL,
  email VARCHAR(190) NULL,
  phone VARCHAR(50) NULL,
  notes TEXT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_contacts_workspace_id (workspace_id)
);

The exact fields will vary by app. What matters for the generator is:

  • the table already exists
  • the ownership columns match the ownership filter you are going to choose
  • the column names are stable enough to become part of your app's resource contract
  • if you are using crud-server-generator, do not hand-write a separate CRUD migration for this table; the server generator installs the CRUD migration scaffold itself

In this table, workspace_id is the important ownership clue. That is why the next step uses:

bash
--ownership-filter workspace

This CRUD is not "my contacts." It is "the workspace's contacts."

Step 2: scaffold the server package

Now generate the server-side CRUD package:

bash
npx jskit generate crud-server-generator scaffold \
  --namespace contacts \
  --surface admin \
  --ownership-filter workspace \
  --table-name contacts

This creates an app-local package under packages/contacts/.

If the table should already be CRUD-owned but should not expose public HTTP CRUD routes yet, add:

bash
--internal

That keeps the generated repository, service, actions, provider, shared resource, and CRUD migration ownership chain exactly the same. The only difference is that the generated HTTP CRUD routes are marked internal, so the public HTTP runtime does not register them.

Use that when:

  • other server modules need a proper CRUD-backed table and shared resource contract
  • you want to avoid direct knex
  • you are not ready to expose list/view/create/update/delete URLs yet

Do not use --internal as a substitute for ownership or permissions design. It is only about whether the public HTTP CRUD routes exist.

Before generating anything, decide these with the developer:

  • which operations are allowed for this CRUD
  • which fields belong in the list view if a list exists
  • what the view form should look like
  • what the edit/new form should look like

The most important file from the UI point of view is:

text
packages/contacts/src/shared/contactResource.js

That file is the shared CRUD contract. The UI generator reads it to decide:

  • which operations exist
  • which fields are readable or writable
  • which relations are exposed
  • how to build the generated pages

So even though the server scaffold writes many files, the resource file is the bridge between the server and UI halves.

One install boundary to remember

crud-server-generator scaffold also adds a new local app package dependency such as:

text
@local/contacts

So before you build or run the app again, install that new local package:

bash
npm install

If you are verifying the guide against a local JSKIT checkout and have already been using local package links, rerun:

bash
npm run devlinks

The same rule applies after later server scaffolds such as addresses and comments. The UI generator can still read the generated resource file directly, but the app runtime needs the local package install boundary to be completed before the CRUD can boot normally.

For standard CRUDs, that file is intentionally compact. It uses defineCrudResource(...) from @jskit-ai/resource-crud-core, authors the canonical schema / searchSchema / defaultSort / autofilter shape once, and lets JSKIT derive the standard CRUD operation contracts from it.

Step 3: scaffold the UI

Once the resource file exists, generate the UI route tree:

bash
npx jskit generate crud-ui-generator crud \
  w/[workspaceSlug]/admin/contacts \
  --resource-file packages/contacts/src/shared/contactResource.js \
  --id-param contactId \
  --display-fields fullName,email,phone

That creates the baseline CRUD route tree:

  • w/[workspaceSlug]/admin/contacts/index.vue
  • w/[workspaceSlug]/admin/contacts/new.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/index.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/edit.vue
  • shared _components files under the same route root

This is the most important mental model in the whole chapter:

  • crud-server-generator creates the reusable CRUD contract and server package
  • crud-ui-generator turns that contract into app-owned pages

If you want to see exactly what lands in packages/contacts/, which files own repository/service/action logic, and how search/filter customization is supposed to work, continue with Advanced CRUDs once this baseline flow is clear.

Why this is the baseline example

contacts is the cleanest first example because nothing fancy is happening yet:

  • it has a normal routed list page
  • it has a normal routed view page
  • it has normal new and edit pages
  • it uses one straightforward resource contract

That is the shape to learn first.

Example 2: addresses

Now move to a child CRUD with its own URL.

This is the right pattern when the child collection deserves its own list page, but should still be reached from a specific parent record.

Example route:

text
.../contacts/3/addresses

In the guide app's real route tree, the visible URL becomes:

text
w/[workspaceSlug]/admin/contacts/[contactId]/addresses

This example is intentionally not an in-page child list and not a contact subtab.

The intended UX is:

  • addresses gets its own routed list page
  • the page lives under a specific contact
  • the user reaches it from an explicit link or button on the contact view
  • it does not appear in the global left menu

That makes it different from the later comments example, where the child list stays inside the parent page.

Step 1: create the child table

Example:

sql
CREATE TABLE addresses (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  workspace_id BIGINT UNSIGNED NOT NULL,
  contact_id BIGINT UNSIGNED NOT NULL,
  label VARCHAR(100) NULL,
  line_1 VARCHAR(190) NOT NULL,
  line_2 VARCHAR(190) NULL,
  suburb VARCHAR(120) NULL,
  state VARCHAR(80) NULL,
  postcode VARCHAR(20) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_addresses_workspace_id (workspace_id),
  KEY idx_addresses_contact_id (contact_id),
  CONSTRAINT fk_addresses_contact_id
    FOREIGN KEY (contact_id) REFERENCES contacts(id)
);

The important extra column here is contact_id. This is what makes the CRUD a child of a contact instead of a top-level resource.

The foreign key is not optional in this example. It is what lets crud-server-generator emit contactId as a real lookup relation in packages/addresses/src/shared/addressResource.js.

Step 2: scaffold the server package

bash
npx jskit generate crud-server-generator scaffold \
  --namespace addresses \
  --surface admin \
  --ownership-filter workspace \
  --table-name addresses

Then install the generated local package:

bash
npm install

If you are developing against a local JSKIT checkout, relink your app to the local packages before you run the UI:

bash
npm run devlinks

Step 3: refine the generated lookup metadata by hand

Open packages/addresses/src/shared/addressResource.js and add labelKey: "fullName" to the generated contactId relation:

js
contactId: {
  type: "id",
  required: true,
  search: true,
  relation: {
    kind: "lookup",
    namespace: "contacts",
    valueKey: "id",
    labelKey: "fullName"
  },
  belongsTo: "contacts",
  as: "contact",
  ui: { formControl: "autocomplete" },
  ...
}

This part is still manual today.

The scaffold can infer the lookup relationship from the foreign key, but it cannot infer which contact field should be used as the human-readable label. Without labelKey: "fullName", the page falls back to generic headings like Addresses for Contact #1.

Step 4: scaffold the UI route tree

bash
npx jskit generate crud-ui-generator crud \
  w/[workspaceSlug]/admin/contacts/[contactId]/addresses \
  --resource-file packages/addresses/src/shared/addressResource.js \
  --id-param addressId \
  --parent-title contextual \
  --display-fields label,line1,postcode

That gives you a normal child route tree:

  • w/[workspaceSlug]/admin/contacts/[contactId]/addresses/index.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/addresses/new.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/addresses/[addressId]/index.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/addresses/[addressId]/edit.vue
  • shared _components files under the same route root

Step 5: remove the generated shell placement by hand

crud-ui-generator currently generates a list-page placement block in src/placement.js.

For this example, that block is not what you want.

Delete the generated addresses placement block from src/placement.js.

It will be the block added for the w/[workspaceSlug]/admin/contacts/[contactId]/addresses page link.

Depending on what outlets already exist in the app, that block may try to place Addresses in the shell menu or in the contact subpages outlet. For this example, remove it either way and keep navigation manual from the contact view.

This workflow has one intentional manual cleanup:

  • keep the routed pages
  • remove the auto menu link
  • navigate to the page from the contact view instead

Open src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue and add a button that links to ./addresses.

The simplest version is:

vue
<v-btn
  color="primary"
  variant="tonal"
  :to="{ path: view.resolveParams(UI_ADDRESSES_URL), query: $route.query }"
>
  Addresses
</v-btn>

Then define the URL template in the script:

js
const UI_ADDRESSES_URL = "./addresses";

That gives you a standalone child page with a clear entry point from the parent contact view, without mixing record-scoped pages into the global shell menu.

The important refinement: parent title handling

This is where nested CRUD starts to feel real.

An addresses list page should not show a generic heading like Addresses. It should usually show whose addresses these are, for example:

  • Addresses for Jane Smith
  • Addresses for Acme Pty Ltd

The awkward case is when there are no child rows yet. If the list is empty, you cannot derive the parent title from the first address record.

That is why JSKIT has useCrudListParentTitle():

js
const parentTitle = useCrudListParentTitle({
  listRuntime: records,
  resource: uiResource,
  recordIdParam: "addressId",
  fallbackLoadError: "Unable to load contact.",
  notFoundMessage: "Contact not found."
});

That helper does the right thing automatically:

  • if the child list already has rows, it derives the parent title from the first child record when lookup data is available
  • if the child list is empty, it loads the parent record directly

So the addresses example is important because it teaches the first truly practical child-page problem:

  • the route is child-scoped
  • the data is child-scoped
  • the page is reached from a parent action, not from the main shell menu
  • the page still needs a reliable parent identity even when the child list is empty

Example 3: comments under the contact host

This is the advanced example.

Sometimes a child resource should stay attached to the parent record, but still deserves its own routed child page.

Comments are a good example:

  • they matter
  • they need persistence
  • they benefit from their own list, new, view, and edit routes
  • but they still belong inside the contact experience rather than as a top-level destination

So the pattern is:

  • keep the contact page as the real host
  • keep the child route tree under that host
  • let the generated placement surface the comments page as a contact child page

Step 1: create the table

Example:

sql
CREATE TABLE comments (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  workspace_id BIGINT UNSIGNED NOT NULL,
  contact_id BIGINT UNSIGNED NOT NULL,
  body TEXT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_comments_workspace_id (workspace_id),
  KEY idx_comments_contact_id (contact_id),
  CONSTRAINT fk_comments_contact_id
    FOREIGN KEY (contact_id) REFERENCES contacts(id)
);

Step 2: scaffold the server package

bash
npx jskit generate crud-server-generator scaffold \
  --namespace comments \
  --surface admin \
  --ownership-filter workspace \
  --table-name comments

Then install the generated local package:

bash
npm install

If you are developing against a local JSKIT checkout, relink the app before you run the UI:

bash
npm run devlinks

Step 3: refine the generated lookup metadata by hand

Open packages/comments/src/shared/commentResource.js and add labelKey: "fullName" to the generated contactId relation:

js
contactId: {
  type: "id",
  required: true,
  search: true,
  relation: {
    kind: "lookup",
    namespace: "contacts",
    valueKey: "id",
    labelKey: "fullName"
  },
  belongsTo: "contacts",
  as: "contact",
  ui: { formControl: "autocomplete" },
  ...
}

This is the same manual refinement used in the addresses example: the scaffold can infer the lookup relationship from the foreign key, but it cannot infer which parent field should be used as the display label.

The next command also makes the parent-title mode explicit on purpose. That way changing the heading behavior later is just changing the value and rerunning with --force, not adding a new flag.

Step 4: make the parent page able to host routed children

If the contact view page should stay visible while child comment routes render underneath it, first upgrade it into a routed host:

bash
npx jskit generate ui-generator add-subpages \
  w/[workspaceSlug]/admin/contacts/[contactId]/index.vue \
  --title "Contact" \
  --subtitle "Contact activity and notes."

That is the point where the comments example deliberately overlaps with the ui-generator chapter. This pattern needs both:

  • CRUD scaffolding for the comments resource
  • a routed host in the parent contact page

Step 4.5: make comments the default child page

This is a very common next step.

If the contact page should open directly on Comments, add an explicit redirect to the contact host page:

vue
<script setup>
import { redirectToChild } from "@jskit-ai/kernel/client/pageRedirects";

definePage({
  redirect: redirectToChild("comments")
});
</script>

In this example, that edit belongs in:

text
src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue

This is the recommended pattern. Do not try to infer the default child page from placement order or "the first tab". Keep it explicit.

When this redirect is present:

  • opening /w/<workspaceSlug>/admin/contacts/<contactId> lands on /w/<workspaceSlug>/admin/contacts/<contactId>/comments
  • the parent contact page still stays visible
  • the child page renders underneath it

That last point matters. The contact page is still the host page. The redirect only changes which child route becomes the default landing destination.

Step 5: generate the full child CRUD page

Now generate the comments UI under the contact host:

bash
npx jskit generate crud-ui-generator crud \
  w/[workspaceSlug]/admin/contacts/[contactId]/index/comments \
  --resource-file packages/comments/src/shared/commentResource.js \
  --id-param commentId \
  --parent-title none \
  --display-fields body

Do not pass --operations view,new,edit here. That would omit the list page and leave you with only child operation routes.

--parent-title none is the important difference from addresses.

The contact host page already shows the parent identity, so a generated heading like Comments for Tony Mobily is redundant. If you later decide you do want that heading, rerun the same command with --parent-title contextual --force.

This command gives you:

  • w/[workspaceSlug]/admin/contacts/[contactId]/index/comments/index.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/index/comments/new.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/index/comments/[commentId]/index.vue
  • w/[workspaceSlug]/admin/contacts/[contactId]/index/comments/[commentId]/edit.vue

Because this route root lives under .../[contactId]/index/comments, the generated placement is the desired one here: Comments appears as a child page under the contact host.

That is the real lesson of the comments example:

  • the contact page stays the routed host
  • the child CRUD gets its own list page
  • the route still stays under the parent record experience

When scaffold-field matters

After the initial scaffold, schema changes are normal.

Suppose you add a preferred_name column to contacts. You do not have to regenerate the whole package just to expose that one new writable field.

That is what scaffold-field is for:

bash
npx jskit generate crud-server-generator scaffold-field \
  preferredName \
  packages/contacts/src/shared/contactResource.js \
  --table-name contacts

Use this when:

  • the CRUD package already exists
  • the table has changed
  • you want to patch one writable field into the generated resource file

It is a maintenance tool, not the first step in the workflow.

It patches the canonical schema inside the shared resource file. That matters because the standard CRUD validators are derived from that canonical schema; you are no longer maintaining separate authored create / patch / view validator blocks by hand.

Summary

The important thing is not memorizing commands. It is learning the three shapes:

  • contacts: a normal top-level CRUD
  • addresses: a child CRUD with its own routed list page reached from the parent record view
  • comments: a child CRUD that lives under the parent record host as a child page

Once you understand those three shapes, the two generator packages stop feeling like separate tools.

They become one workflow:

  1. model the table
  2. scaffold the server/resource package
  3. scaffold only the UI shape that the feature actually needs

The next chapter is Advanced CRUDs. Read it as the structural follow-on to this one: this chapter teaches how to generate the CRUD, and the next one teaches how to reason about the generated files the app owns.

JSKIT documentation