CRUD Generators
CRUD generation in JSKIT is really one workflow split across two generator packages:
crud-server-generatorcreates the app-local server/resource packagecrud-ui-generatorcreates the route tree that uses that shared resource contract
So the real order is always:
- create a real table
- scaffold the server package around it
- scaffold the UI around the generated resource file
This chapter uses three examples, in increasing complexity:
contacts: the baseline full CRUD walkthroughaddresses: a child CRUD with its own routed list page reached from the parent record viewcomments: 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:
listviewnewedit
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:
| Value | Plain meaning | Typical owner columns |
|---|---|---|
auto | infer ownership from the real table during generation | inferred from table shape |
public | no owner scoping | no workspace_id or user_id required |
user | rows belong to one user | user_id |
workspace | rows belong to one workspace | workspace_id |
workspace_user | rows belong to one workspace and one user together | workspace_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_iduser_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_idanduser_idpresent ->workspace_userworkspace_idonly ->workspaceuser_idonly ->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
publicfor shared reference/config data - choose
userfor "my own" records - choose
workspacefor workspace-wide business data - choose
workspace_userfor personal records that still live inside a workspace - choose
autowhen 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
publicoruser
One more important constraint
Ownership also has to make sense for the target surface.
In practice:
workspaceandworkspace_userbelong on workspace-enabled surfacespublicandusercan live on non-workspace surfaces too
So if you are generating under a route like:
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:
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:
--ownership-filter workspaceThis CRUD is not "my contacts." It is "the workspace's contacts."
Step 2: scaffold the server package
Now generate the server-side CRUD package:
npx jskit generate crud-server-generator scaffold \
--namespace contacts \
--surface admin \
--ownership-filter workspace \
--table-name contactsThis 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:
--internalThat 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:
packages/contacts/src/shared/contactResource.jsThat 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:
@local/contactsSo before you build or run the app again, install that new local package:
npm installIf you are verifying the guide against a local JSKIT checkout and have already been using local package links, rerun:
npm run devlinksThe 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:
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,phoneThat creates the baseline CRUD route tree:
w/[workspaceSlug]/admin/contacts/index.vuew/[workspaceSlug]/admin/contacts/new.vuew/[workspaceSlug]/admin/contacts/[contactId]/index.vuew/[workspaceSlug]/admin/contacts/[contactId]/edit.vue- shared
_componentsfiles under the same route root
This is the most important mental model in the whole chapter:
crud-server-generatorcreates the reusable CRUD contract and server packagecrud-ui-generatorturns 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
newandeditpages - 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:
.../contacts/3/addressesIn the guide app's real route tree, the visible URL becomes:
w/[workspaceSlug]/admin/contacts/[contactId]/addressesThis example is intentionally not an in-page child list and not a contact subtab.
The intended UX is:
addressesgets 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:
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
npx jskit generate crud-server-generator scaffold \
--namespace addresses \
--surface admin \
--ownership-filter workspace \
--table-name addressesThen install the generated local package:
npm installIf you are developing against a local JSKIT checkout, relink your app to the local packages before you run the UI:
npm run devlinksStep 3: refine the generated lookup metadata by hand
Open packages/addresses/src/shared/addressResource.js and add labelKey: "fullName" to the generated contactId relation:
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
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,postcodeThat gives you a normal child route tree:
w/[workspaceSlug]/admin/contacts/[contactId]/addresses/index.vuew/[workspaceSlug]/admin/contacts/[contactId]/addresses/new.vuew/[workspaceSlug]/admin/contacts/[contactId]/addresses/[addressId]/index.vuew/[workspaceSlug]/admin/contacts/[contactId]/addresses/[addressId]/edit.vue- shared
_componentsfiles 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
Step 6: add a manual link from the contact view
Open src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue and add a button that links to ./addresses.
The simplest version is:
<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:
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 SmithAddresses 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():
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, andeditroutes - 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:
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
npx jskit generate crud-server-generator scaffold \
--namespace comments \
--surface admin \
--ownership-filter workspace \
--table-name commentsThen install the generated local package:
npm installIf you are developing against a local JSKIT checkout, relink the app before you run the UI:
npm run devlinksStep 3: refine the generated lookup metadata by hand
Open packages/comments/src/shared/commentResource.js and add labelKey: "fullName" to the generated contactId relation:
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:
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:
<script setup>
import { redirectToChild } from "@jskit-ai/kernel/client/pageRedirects";
definePage({
redirect: redirectToChild("comments")
});
</script>In this example, that edit belongs in:
src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vueThis 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:
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 bodyDo 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.vuew/[workspaceSlug]/admin/contacts/[contactId]/index/comments/new.vuew/[workspaceSlug]/admin/contacts/[contactId]/index/comments/[commentId]/index.vuew/[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:
npx jskit generate crud-server-generator scaffold-field \
preferredName \
packages/contacts/src/shared/contactResource.js \
--table-name contactsUse 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 CRUDaddresses: a child CRUD with its own routed list page reached from the parent record viewcomments: 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:
- model the table
- scaffold the server/resource package
- 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.