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…WriteAnd add a line to
owns tablesschema.tssrc/core/db/schema.ts
has console pagespermissions.ts — registerPermissionsrc/core/authz/handlers.ts
has operator-editable valuessettings.ts — registerSettingsrc/core/settings/handlers.ts
reacts to paymentsserver/events.ts — onPaymentEventsrc/core/payment/handlers.ts
shows figures on /adminserver/stats.ts — registerStatssrc/core/stats/handlers.ts
has per-user datacomponents/admin-user-section.tsx — registerAdminUserSectionsrc/core/user-detail/handlers.ts
stores per-user rowsserver/account.ts — onAccountExport, onBeforeAccountDelete, onAccountDeletedsrc/core/account/handlers.ts
has scheduled workserver/jobs.ts — registerJobsrc/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.