Worked feature example
This is the smallest maintained feature to copy when adding a setting that an operator manages and the app consumes: feature flags. It already crosses the contract, API, admin UI, localization, browser regression and the generated free edition. Start from these files instead of creating a parallel example domain.
Feature flags are the kit’s own, so every file below is a kit file. A feature of yours follows the
same layers in the product files instead, which kit releases never change: its tables in
schema/product.ts, its procedures in api-contract/src/product.ts, its handlers in
rpc/product.ts. Where your code goes lists them all.
For data that belongs to one organization or workspace, such as your customers’ own records, follow Your first tenant-scoped feature instead.
The path
Section titled “The path”packages/db → packages/api-contract → apps/api/src/rpc → apps/web admin form → #lib/flags.js persistence schema authorization Superforms feature gate1. Persist the global default
Section titled “1. Persist the global default”packages/db/src/schema/flags.ts defines the durable feature_flag row. packages/db/src/flags.ts
owns upsertFlag(), deleteFlag() and the resolved read, so handlers do not repeat database
queries or precedence rules.
The existing packages/db/drizzle/0004_feature_flags.sql migration created the flag tables; add a
new migration only when the schema itself changes.
The same schema has feature_flag_override, keyed by organization then flag. effectiveFlags()
loads that tenant’s overrides and resolveFlags() lets an override win over the global default.
2. Define one input schema in the contract
Section titled “2. Define one input schema in the contract”packages/api-contract/src/index.ts owns the key rules and the full upsert input. The contract
uses that exact object for the REST and RPC procedure, and the web client re-exports it from
@repo/api-client.
export const FeatureFlagUpsertInputSchema = z.object({ key: FlagKeySchema, description: z.string().max(200).refine(isSingleLine, controlCharacterRule).optional(), enabled: z.boolean().optional(),});
upsert: adminProcedure .route({ method: 'PUT', path: '/admin/flags/{key}', tags: ['admin'] }) .input(FeatureFlagUpsertInputSchema) .output(FeatureFlagSchema),Do not copy the validation rules into a Svelte component. Import the schema through
@repo/api-client; the client and server then reject the same key, description and default value.
3. Authorize at the API boundary
Section titled “3. Authorize at the API boundary”apps/api/src/rpc/base.ts resolves the Better Auth session and checks the account-level platform
role. apps/api/src/rpc/admin.ts applies that middleware to every flags procedure, including
reads. Hiding the route in the browser is only a convenience; it is not authorization.
export const requireAdmin = os.middleware(async ({ context, next }) => { const session = await resolveSession(context.headers); if (!isAdmin((session.user as { role?: string | null }).role)) throw new ORPCError('FORBIDDEN'); return next({ context: { session } });});
export const adminOnly = os.use(requireAdmin);upsert: adminOnly.admin.flags.upsert.handler(async ({ input }) => { const flag = await upsertFlag(input); const overrides = (await listFlags()).find((f) => f.key === flag.key)?.overrides ?? []; return { ...flag, overrides };}),An organization owner or member is not automatically a platform admin. See Admin for how accounts receive that separate role.
4. Use the shared Superforms adapter in the browser
Section titled “4. Use the shared Superforms adapter in the browser”apps/web/src/routes/(admin)/admin/flags/+page.svelte initializes the form with the contract
schema. #lib/forms.js supplies the standard SPA options and zodForm(), which maps Zod issues to
Paraglide messages at validation time. FormField connects the label, error, invalid state and
description for each input.
const create = superForm( defaults({ key: '', description: '', enabled: false }, zodForm(FeatureFlagUpsertInputSchema)), { ...spaForm, validators: zodForm(FeatureFlagUpsertInputSchema), async onUpdate({ form }) { if (!form.valid) return; const { key, description, enabled } = form.data; const { error, data: flag } = await safe( api.admin.flags.upsert({ key, description: description?.trim(), enabled }), ); if (error) return setError(form, messageOf(error)); put(flag); }, },);The complete form trims the optional description before it sends it, blocks duplicate keys in its already-loaded list, keeps input on a failed request and resets only after a successful submission. Use the complete source for those details rather than this shortened excerpt.
5. Read the resolved value where it changes behavior
Section titled “5. Read the resolved value where it changes behavior”apps/web/src/lib/flags.ts exposes the one reactive read every component uses:
{#if flag('release-notes')} <ReleaseNotes />{/if}The (app) layout loads the global default plus any active organization’s override. The API first
checks membership of that organization, so a signed-in user cannot resolve another tenant’s flags.
flagsFor() returns an empty map when the API is unreachable. A gated feature is therefore off
until the server can resolve it.
Run the walkthrough
Section titled “Run the walkthrough”-
Complete the Quickstart install and environment setup, then start the local database, apply the existing migration history and run the app:
Terminal window bun run doctorbun run db:upbun run db:migratebun run dev:app -
Create a platform administrator as described in Admin, sign in, and open
http://localhost:5173/admin/flags. -
Enter
Release Notesas a key and submit. The browser must show the localized lowercase-slug validation error and make no request to create a flag. -
Enter
release-notes, add a description and submit. The list should immediately contain the new flag; use the toggle to change its global default. -
Sign in as an ordinary
useraccount and request/api/v1/admin/flagswith that account’s session. It must receive403 FORBIDDEN; a client-side redirect or hidden admin navigation does not count as the check.
- Back as the platform administrator, expand the flag, select an organization and add an on or
off override. Switch to that organization in the app:
flag('release-notes')now follows the override instead of the global default. Remove the override to follow the default again.
What the automated checks cover
Section titled “What the automated checks cover”| Check | Evidence | Boundary |
|---|---|---|
| Browser form regression | apps/web/src/lib/admin-flags-page.svelte.spec.ts submits an invalid key, a successful create and a rejected request. |
It runs the real Svelte component in Chromium and mocks only the typed API client. It proves browser validation, translated error rendering, pending submission behavior and retained input; it does not persist a database row. |
| API authorization regression | apps/api/src/rpc/admin-authorization.test.ts calls the real requireAdmin middleware as an authenticated user. |
Better Auth session lookup is replaced with that fixed non-admin session so the test proves it answers FORBIDDEN before a database handler runs. apps/api/src/app.test.ts separately checks that anonymous admin routes answer 401. |
| Flag resolution | packages/db/src/migrations.test.ts verifies that an override wins over the global default. |
It is a pure resolver test; perform step 6 above against a local database before changing persistence or tenant isolation. |
Run the focused checks from the repository root:
cd apps/web && bunx vitest --run src/lib/admin-flags-page.svelte.spec.tscd ../.. && SKIP_ENV_VALIDATION=1 bun test apps/api/src/rpc/admin-authorization.test.ts