Adding features
Build a new module as a vertical slice — schema, server functions, route, registries — and keep it deletable with a verified recipe.
Everything optional in ShipKit — credits, files, feedback, the waitlist — is a
vertical slice: one folder under src/features/<name>/ that owns its tables,
server code, queries and components, plus a handful of one-line registrations
in shared files. Your own modules should look the same. It keeps each one easy
to find, and it means you can take one out again with the same confidence as
the bundled ones (see Deleting features).
Start with /add-feature
If you work with Claude Code or another agent, run the /add-feature skill
(.claude/skills/add-feature/SKILL.md). It walks the full wiring checklist
below in order and finishes with the same checks this page ends on. See
AI agents for how the skills fit together.
Whether you use it or not, copy an existing slice rather than starting from a
blank folder. src/features/credits/ is the reference implementation and
touches almost every registry; src/features/feedback/ is smaller and a good
model for "a table, a user form and an admin page".
The shape of a slice
src/features/<name>/
├── schema.ts # Drizzle tables, if the feature owns data
├── config.ts # client-safe constants (status lists, limits)
├── permissions.ts # admin permissions, if it has console pages
├── settings.ts # operator-editable values, if any
├── queries.ts # queryOptions wrapping the server functions
├── server/
│ ├── fns.ts # server functions (authedFn / adminFn)
│ └── ... # events.ts, stats.ts, account.ts, jobs.ts as needed
└── components/ # the feature's own components
src/routes/_app/<name>.tsx # user page (session-guarded)
src/routes/_app/admin/<name>.tsx # console page, if any
Only create the files you need. A feature with no tables has no schema.ts; one
that never appears in the console has no permissions.ts.
Step by step
1. Schema
Define tables in schema.ts: text ids from crypto.randomUUID(), timestamps as
integer({ mode: 'timestamp_ms' }), money in integer cents, and a foreign key
to user with onDelete: 'cascade' so the rows go when the account does. Add
one line to the schema barrel, then generate and apply the migration:
// src/core/db/schema.ts
export * from '@/features/<name>/schema'
bun run db:generate
bun run db:migrate:local
Database covers the D1 specifics, including why there are no interactive transactions.
2. Server functions
Every read and write goes through a server function built from authedFn
(signed-in user) or adminFn (console) in src/core/server/fn.ts. The session
arrives in context; input is validated with zod:
export const submitThingFn = authedFn
.validator(z.object({ title: z.string().trim().min(1).max(200) }))
.handler(async ({ data, context }) => {
await getDb().insert(thing).values({
userId: context.session.user.id,
title: data.title,
})
return { success: true }
})
An adminFn only proves the caller may open the console. Each one must also
name its own permission with await assertPermission(context.session, '<name>.read').
Server functions return keys and values, never translated text. The details are
in Server functions.
3. Queries and the route
Wrap each read in queryOptions in queries.ts. The route's loader calls
context.queryClient.ensureQueryData(...), the component reads with
useSuspenseQuery, and mutations invalidate the query key. A page under
src/routes/_app/ inherits the session guard. After adding a route file:
bun run generate-routes
4. Copy
Add every string to both messages/en.json and messages/zh.json, with keys
prefixed <name>_, and call m.<key>() in components. See
Internationalization.
5. Navigation
A user page gets a row in mainNav in src/components/app-sidebar.tsx. Wrap
it in markers (more on those below):
// [feature: <name>] start
{ to: '/<name>', label: () => m.<name>_title(), icon: SomeIcon },
// [feature: <name>] end
A card on /dashboard is optional; if you add one, fence it the same way.
Plugging into the registries
Shared behaviour — payments, the admin overview, account deletion, cron — is
wired through registries. Each has a handlers.ts in src/core/ with one
import line per feature; your file calls a register function when it loads.
Add only the ones your feature needs:
| If your feature… | Write | And add a line to |
|---|---|---|
| owns tables | schema.ts | src/core/db/schema.ts |
| has console pages | permissions.ts — registerPermission | src/core/authz/handlers.ts |
| has operator-editable values | settings.ts — registerSetting | src/core/settings/handlers.ts |
| reacts to payments | server/events.ts — onPaymentEvent | src/core/payment/handlers.ts |
shows figures on /admin | server/stats.ts — registerStats | src/core/stats/handlers.ts |
| has per-user data | components/admin-user-section.tsx — registerAdminUserSection | src/core/user-detail/handlers.ts |
| stores per-user rows | server/account.ts — onAccountExport, onBeforeAccountDelete, onAccountDeleted | src/core/account/handlers.ts |
| has scheduled work | server/jobs.ts — registerJob | src/core/jobs/handlers.ts |
Calling audit(), notify(), sendEmailSafely() or track() needs no
registration: those dispatchers are in core, and the features that store their
output (audit log, notifications, email log, analytics) are the ones with lines
in the sink registries.
Two rules come with the registries. permissions.ts and settings.ts are
loaded by the browser too, so they must import nothing server-only — put
defaults in a client-safe config.ts. And a payment handler must be idempotent:
store the external order id in a unique column and insert with
onConflictDoNothing, because webhooks are redelivered. Jobs must tolerate
running twice for the same reason.
Admin pages
A console page needs a permission, a gated route, gated server functions and a sidebar row:
// src/features/<name>/permissions.ts
registerPermission({
key: '<name>.read',
group: '<name>',
groupLabel: () => m.admin_<name>_title(),
label: () => m.perm_read(),
})
// src/routes/_app/admin/<name>.tsx
export const Route = createFileRoute('/_app/admin/<name>')({
beforeLoad: async ({ context }) => {
await requirePermission(context.queryClient, '<name>.read')
},
// loader, component…
})
Then add an adminNav row in app-sidebar.tsx with to, permission, label
and a section — one of overview, people, revenue, developers,
settings — which makes the page a tab of that section. Tables use DataTable
from src/components/data-table.tsx. The full picture is in
Permissions and Admin console.
You do not have to remember all of this: src/core/authz/registry.test.ts fails
if an admin route skips its permission check, if an adminFn export never calls
assertPermission, or if a permissions.ts is missing from its handlers.ts.
Keeping it deletable
Anything your feature adds to a file it does not own — a sidebar row, a dashboard card, an import in the app shell — goes between markers, so a script can cut it out:
// [feature: <name>] start
…
// [feature: <name>] end
{/* [feature: <name>] start */}
…
{/* [feature: <name>] end */}
import { Thing } from '@/features/<name>/components/thing' // [feature: <name>]
Then add a recipe for it to scripts/verify-deletion.ts. A recipe deletes the
feature's folder and routes, strips its registry lines with
stripLines(['features/<name>']) and its marked blocks with
stripFeatureBlocks('<name>'). The existing recipes are the examples to copy.
Run it:
bun run verify:deletion <name>
It copies the repo, applies your recipe, and requires build, typecheck and the unit tests to pass. CI builds its matrix from the recipe list, so a new recipe is checked on every pull request without editing the workflow.
Definition of done
bun run typecheck && bun run check && bun run build && bun run verify:deletion <name>
Add an end-to-end case as well — a e2e/<name>.spec.ts that opens the page and
asserts its heading and one interaction — and run bun run e2e. Match the
design language in src/CLAUDE.md: hairline borders, no shadows or gradients,
no colored accent.