Skip to content

Where your code goes

A kit release reaches your repository as a git merge (UPGRADING.md), and a merge conflicts wherever both sides changed the same lines. So each place your product plugs into the kit is a file of its own, next to the kit code that reads it. The kit created each of these files as an empty stub, in 1.1.0 or a later release, and no release changes one afterwards: what you add there never conflicts. A later extension point arrives as a new file.

What you add Your file Read by
Tables packages/db/src/schema/product.ts packages/db/src/client.ts and drizzle.config.ts
Query helpers packages/db/src/product.ts packages/db/src/index.ts, so they import from @repo/db
Dependencies of your server code packages/product-server/package.json apps/api, which depends on it, so the API’s install and turbo’s graph
Dependencies of your web code packages/product-web/package.json apps/web, which depends on it, likewise
Dependencies of your query code in packages/db packages/product-db/package.json packages/db, which depends on it, likewise
Counters for the per-organization plan limits you add packages/db/src/product-usage.ts packages/db/src/usage.ts, so organizations.usage and billing.status
A rule of your own for which workspaces a member sees, on top of the team boundary packages/db/src/product-tenancy.ts visibleWorkspace in packages/db/src/tenancy.ts, so every workspace lookup and the tenant guards
Migrations packages/db/drizzle-product/, generated bun run db:migrate, after the kit’s
Procedures, and the schemas clients share packages/api-contract/src/product.ts the contract, and @repo/api-client, which re-exports the whole file
Job kinds customers see packages/api-contract/src/product-jobs.ts JOB_KINDS in the contract, so the Jobs list and jobs.*
Webhook event types your product emits packages/api-contract/src/product-webhooks.ts WEBHOOK_EVENT_TYPES in the contract, so endpoints can subscribe to them
Handlers apps/api/src/rpc/product.ts apps/api/src/rpc/router.ts
Raw HTTP routes for what a procedure cannot be (images, streaming downloads), and the path prefixes the access log and tracing leave out apps/api/src/product-http.ts apps/api/src/app.ts, after the kit’s routes, and index.ts for tracing
Bodies your raw routes take above the API’s default, by path prefix apps/api/src/product-body-limits.ts apps/api/src/middleware/body-limit.ts, so app.ts and index.ts, and bun run serve’s proxy
Procedures a public demo refuses, with the message an API client gets apps/api/src/rpc/product-demo.ts refuseInDemo in apps/api/src/rpc/base.ts
What your product stops when the process shuts down (productShutdown) apps/api/src/product-lifecycle.ts apps/api/src/lifecycle.ts, as the drain begins
Background job kinds (productJobs) and recurring tasks (productSchedules) apps/api/src/jobs/product.ts the worker: apps/api/src/jobs/handlers.ts and schedules.ts
Long-running loops each worker process runs (productLoops) apps/api/src/jobs/product-loops.ts the worker, apps/api/src/jobs/worker.ts, which starts them with it and stops them at shutdown
Kinds of stored file: attachments, logos, avatars, exports apps/api/src/files/product.ts files.*, through apps/api/src/files/registry.ts
Email templates, in English and German packages/email/src/product.ts templates in @repo/email, next to the kit’s
Environment variables, the rules across them, and which of them are secrets packages/env/src/product.ts packages/env/src/index.ts, so env from @repo/env and the start-up checks
Your environment rows, explained, and the secrets setup generates .env.product.example bun run setup (into .env), infra/scripts/provision.sh, bun run doctor
Seed data for an account the kit creates: the demo owner and the browser suites’ user packages/auth/scripts/product-seed.ts bun run seed and bun run test:e2e:full (packages/auth/scripts/e2e-user.ts)
What your product ends when a session ends: a token signed for it, a grant packages/auth/src/product-sessions.ts packages/auth/src/session-events.ts, from Better Auth’s session delete hook in server.ts
Permission statements, what each role may do, and which of them an API key may hold (productApiKeyScopes) packages/auth/src/product-permissions.ts packages/auth/src/permissions.ts, so orgScoped, hasPermission and apiKeyScopes too
Organization roles of your own, beyond owner, admin and member, with everything each one grants packages/auth/src/product-roles.ts packages/auth/src/permissions.ts, so the role pickers on /app/organization/members, invitations and every permission check
Signed-in procedures on a user’s own rows that take an id, and why they need no tenant guard apps/api/src/rpc/product-tenancy-allowlist.ts apps/api/src/rpc/tenancy.test.ts
Top-level pages under /app/ a workspace slug must not take packages/api-contract/src/product-workspace.ts RESERVED_WORKSPACE_SLUGS
Value lists your tables and your contract share: statuses, kinds, roles packages/api-contract/src/product-values.ts @repo/api-contract/values, which packages/db imports, so a CHECK constraint and a z.enum() read one array
Messages, in English and German apps/web/messages/{en,de}/product.json, and product-<area>.json for an area with many Paraglide, as m.product_<page>_<element>(); i18n:compile and vite list each file in project.inlang/settings.json
Your own wording for a kit message apps/web/messages/{en,de}/product-overrides.json, which you create: the kit ships none Paraglide, after every other catalogue, so it wins
Copy for your contract’s refusals (params: { reason }) apps/web/src/lib/product-forms.ts issueMessage in apps/web/src/lib/forms.ts, so every zodForm
Links in the navigation and the command palette apps/web/src/lib/product.ts the app shell’s sidebar and CommandPalette.svelte, after Home
Sections of the settings dialog: pages people configure rather than use apps/web/src/lib/product-settings.ts apps/web/src/lib/settings.ts, so the settings dialog, after the kit’s sections, and the command palette
Commands in the command palette: actions, entries that depend on the page apps/web/src/lib/product-commands.ts CommandPalette.svelte, after its navigation
A step when someone signs out of this browser: revoke a token you issued, clear your pages’ storage apps/web/src/lib/product-sign-out.ts signOut() in apps/web/src/lib/sign-out.ts, so every sign-out in the app
Origins your pages load from (a tile server, a font CDN) for the Content-Security-Policy apps/web/product-csp.ts apps/web/csp-sources.ts, so csp() in apps/web/vite.config.ts and the shells’ apps/native/scripts/tauri-config.ts
Tabs on a workspace’s pages apps/web/src/lib/product-workspace.ts the workspace layout, before Settings
Where a link to a workspace opens, and the kit’s workspace pages your product leaves out of its tabs apps/web/src/lib/product-workspace-pages.ts WorkspaceNav.svelte, the home page, CommandPalette.svelte and the workspace layout
Labels for your job kinds, plan limits, plan features, roles and audit-log categories, in English and German apps/web/src/lib/product-labels.ts apps/web/src/lib/jobs.ts, plans.ts, i18n.ts and AuditTable.svelte, so the Jobs list, /app/billing, the role pickers and the audit log’s filter
Release notes, one file per release apps/site/src/content/changelog/ the site’s /changelog
Blog posts, one file per post apps/site/src/content/blog/ the site’s /blog and /rss.xml
Documentation for your users apps/docs/src/content/product/ the docs build, which publishes it by default
Steps of your own before and after each deploy, one *.sh per step infra/scripts/hooks/pre-deploy.d/ and post-deploy.d/ infra/scripts/deploy.sh, in name order (infra/scripts/hooks/README.md)
Debian packages your API needs at run time apps/api/product-apt-packages.txt apps/api/Dockerfile, which installs them into the API’s image
Path prefixes, rows, checks and processes of yours for bun run serve scripts/serve/product.ts scripts/serve/hooks.ts, so the origin proxy, start, preflight and doctor -- --served
Notices for your dependencies’ licences THIRD-PARTY.product.md people, and bun run licenses --strict, which checks it names each package you acknowledge
The packages whose licence you acknowledge, and licences a package declares wrongly scripts/product-licenses.ts bun run licenses (scripts/licenses.ts)
Architecture decisions about your product docs/product-decisions.md people and agents (AGENTS.md names it)

The rest of a feature goes in new files of yours, which never conflict either: a schema file exported from schema/product.ts, a contract file spread into productContract, a router added to productRouter, its pages under apps/web/src/routes/, and their tests. Your first tenant-scoped feature builds one end to end.

Some of these files have a guide of their own:

Four rules keep these files working:

  • Import the kit’s modules, never the file that imports yours. The contract’s index.ts imports product.ts, so a contract file of yours builds on ./procedures, which holds the builders and the text rules, never on ./index. SlugSchema and CursorSchema are in ./procedures too. A query helper imports ./client and ./schema, never ./index; so does a visibility rule in packages/db/src/product-tenancy.ts, which tenancy.ts imports; packages/env/src/product.ts imports ./configuration-issue and ./parse, never ./index; packages/auth/src/product-sessions.ts imports ./session-events, never ./server; and scripts/serve/product.ts imports ./types, never ./hooks. Importing back is a cycle that fails at start-up. Tests of your environment may import packages/env/src/testing.ts, which imports nothing of the package. apps/api/src/product-body-limits.ts imports nothing at all: bun run serve reads it in a process of its own, before the API starts. packages/api-contract/src/product-values.ts, and any file it re-exports, imports no package: packages/db loads it through @repo/api-contract/values, and values.test.ts fails when it does. The kit’s specs mock the hooks the app’s shell reads, product.ts, product-commands.ts, product-sign-out.ts and product-settings.ts, so those may import what your pages need. They mock product-workspace.ts and product-workspace-pages.ts too. product-forms.ts is loaded as it is, by the kit’s specs too: keep it to messages and types. product-labels.ts is loaded the same way, under the same rule.
  • Names of your own. The contract, the router and the environment take your entries after the kit’s, and a key the kit already uses fails bun run check (an environment variable’s message names it and product.ts, and the API stops at start-up too). So does a resource in product-permissions.ts named like one of the kit’s or Better Auth’s, such as workspace, member or team, and a role in product-roles.ts named owner, admin or member. A path prefix of yours, quiet or capped, that covers or sits under one of the kit’s routes (apps/api/src/route-prefixes.ts) stops the API at start-up, and a served one stops bun run serve.
  • Values that work as written. bun run setup appends a row of .env.product.example that your .env lacks with the value written there, and infra/scripts/provision.sh appends every row to a new server’s .env, so each value must be safe to append to a real .env as it is. Leave a row empty to take the default packages/env/src/product.ts gives it, and give an example path or address in the comment above it; a secret ships as change-me…, which both generate, and a key a provider issues ships empty.
  • The kit never names a file of its own product.ts, product-*.ts, product.json, product-*.json, .env.product.example, THIRD-PARTY.product.md, docs/product-decisions.md or product-apt-packages.txt, never adds to packages/product-server, packages/product-web or packages/product-db, and never adds to drizzle-product/, the site’s changelog/ and blog/, src/content/product/ or the two deploy hook folders. Anything with such a name or in such a folder is yours. The kit tests each hook with a fixture, in a test named after the kit file that reads it (csp-sources.spec.ts, licenses.test.ts), and a kit test that needs a stub as shipped takes it from scripts/shipped-stubs.fixture.ts, never your copy, so it passes whatever you put in yours; a test of what you put in a hook is yours: name it product-* or put it beside your code. In the root scripts/, which belongs to no package, bun run test runs every scripts/product*.test.ts with the kit’s own script tests. A stub’s comment says what the file is and which section of this page explains it; the details live here, where a release may correct them.

apps/api depends on packages/product-server, apps/web on packages/product-web and packages/db on packages/product-db, three empty packages of yours. A dependency of your server code goes in the first, one of your pages in the second, and one your query helpers in packages/db/src/ import in the third, never in a kit manifest, which a release changes:

Terminal window
bun add --cwd packages/product-server sharp

A workspace package of your own goes there the same way, as "@acme/media": "workspace:*", then bun install. Code in apps/api imports it directly (import sharp from 'sharp'): the root node_modules holds it, because bunfig.toml hoists, and turbo orders and caches the app after the package, so a change there runs the app’s checks and tests again. With linker = "isolated", list the dependency in the app that imports it instead.

No other kit package (auth, env, …) depends on these, so a package of yours in the first two may depend on @repo/db without a cycle; code that needs both lives in your package or in apps/api. A package in packages/product-db must not depend on @repo/db, which depends on it: turbo refuses the cycle. Put there what your query code shares with the rest of your product, such as a package of your own that both packages/db and the API read, and keep it free of the database. The API’s and the web app’s images copy packages/ before they install, so their dependencies reach the images with no Dockerfile change. Bun installs nothing at run time here (install.auto = "disable" in bunfig.toml): a package no manifest lists fails with Cannot find package.

bun run licenses reads every installed package’s declared licence, and every crate the desktop shell’s Cargo.lock holds, through cargo metadata. Record a dependency of yours in THIRD-PARTY.product.md, not THIRD-PARTY.md, which is the kit’s. When its licence needs a notice, write the notice there and put the package’s name, or the prefix its platform builds share (@img/sharp-), in productAcknowledged in scripts/product-licenses.ts: the scan still prints it, marked “noted in THIRD-PARTY.product.md”, and --strict passes it. When a package declares one licence and ships another (a wrapper that bundles a program under other terms), correct it in productLicenseOverrides:

export const productLicenseOverrides: Readonly<Record<string, LicenseOverride>> = {
'some-wrapper': {
license: 'Artistic-1.0-Perl OR GPL-1.0-or-later',
reason: 'bundles a Perl program',
},
};

The scan then reads that expression, printed with the declared one and your reason. An override needs both, and neither list takes a bare scope. --strict fails when an acknowledged package’s notice file does not name it, when an override matches no installed package, and when it changes nothing: every package it matches now declares what it says, so delete it. With a half of the scan left out (--skip=python), an override that matches nothing is only reported.

Deleting an override, by hand or in a merge, reads the declared licence again, and a permissive one passes. A test of yours keeps the decision: bun run test runs scripts/product*.test.ts, so scripts/product-licenses.test.ts can scan the installed packages and check what they read as.

import { expect, it } from 'bun:test';
import { join } from 'node:path';
import { parseLicenseArgs, scanLicenses } from './licenses';
it('reads some-wrapper under the licence it ships', async () => {
const lines: string[] = [];
const root = join(import.meta.dir, '..');
await scanLicenses(root, parseLicenseArgs(['--all', '--skip=python,rust']), (l) => lines.push(l));
expect(lines.join('\n')).toMatch(/some-wrapper@\S+ — Artistic-1\.0-Perl OR GPL-1\.0-or-later/);
});

A dependency that runs a program of the system needs that program in the API’s image: a Perl library’s perl, an image library’s codecs. List each Debian package in apps/api/product-apt-packages.txt, one per line, with # for comments:

# ExifTool, which exiftool-vendored runs
perl

apps/api/Dockerfile installs them with apt-get install --no-install-recommends before it copies the code, so a code change does not run apt again. Each word is a package name, never a command or a glob, and a package Debian does not have fails the build. Check a new package in a container before you deploy: docker build -f apps/api/Dockerfile -t api-check . && docker run --rm api-check perl -v. Running the API on your own machine (bun run dev, bun run serve), install the package there yourself. The worker runs the same image.

productSeed() in packages/auth/scripts/product-seed.ts writes your rows for an account the kit has just created, and returns lines to print:

  • bun run test:e2e:full calls it with purpose: 'e2e' for the verified user the run’s suites sign in as, on a database made for the run. Give the suites what they need to find: a few records, a file under a TEST_DATA_* path.
  • bun run seed calls it with purpose: 'demo' for demo@example.com, after the kit’s demo rows. It runs on every seed, the account’s first or not, so make it idempotent: look before you insert. A seed of yours that throws on the account’s first run undoes the kit’s rows too.
import { db, eq, notes } from '@repo/db';
export async function productSeed({ user, purpose }: ProductSeedContext) {
const [existing] = await db.select().from(notes).where(eq(notes.authorId, user.id)).limit(1);
if (existing) return [];
await db.insert(notes).values({ id: crypto.randomUUID(), authorId: user.id, title: 'Welcome' });
return [`notes 1 for ${user.email} (${purpose})`];
}

A procedure is declared in the contract and implemented once, as the kit’s are, with a route so that it is also REST under /api/v1 and in the OpenAPI document:

packages/api-contract/src/product.ts
import { z } from 'zod';
import { protectedProcedure } from './procedures';
export const ReportSchema = z.object({ id: z.string(), title: z.string() });
export type Report = z.infer<typeof ReportSchema>;
export const productContract = {
reports: {
list: protectedProcedure
.route({ method: 'GET', path: '/reports', tags: ['reports'], summary: 'Your reports' })
.output(z.array(ReportSchema)),
},
};
apps/api/src/rpc/product.ts
import { listReports } from '@repo/db';
import { authed } from './base';
export const productRouter = {
reports: {
list: authed.reports.list.handler(({ context }) => listReports(context.session.user.id)),
},
};

bun run check fails while a procedure in productContract has no handler in productRouter. The web app imports ReportSchema and Report from @repo/api-client like any kit export, and calls api.reports.list(). A kit export with the same name as one of yours wins there, without an error, so name yours apart.

The kit’s migrations are packages/db/drizzle; yours are packages/db/drizzle-product, each with its own history table. bun run db:generate writes yours from what schema/product.ts exports, and bun run db:migrate applies the kit’s history, then yours. A kit release that adds a migration therefore never touches yours. UPGRADING.md (“Migrations”) covers the order on fresh and existing databases, and what to do when db:migrate reports one still pending.

A table of yours can reference a kit table: import the kit table into your schema file, and never re-export it, or db:generate creates it a second time.

Rather than adding a column to a kit table, add a table of your own keyed by its id, such as user_profile (user_id primary key references "user"). It gets its migration from a plain bun run db:generate, and kit releases never touch it.

When a kit table itself has to change (a Better Auth plugin after bun run auth:schema, an index of yours on a kit table), bun run db:kit-drift prints the SQL your schema needs that none of your migrations holds yet. Put it in bun run db:generate --custom --name=<name> and check that it drops nothing. bun run test fails while any of it is outstanding.

Your messages go in apps/web/messages/en/product.json and apps/web/messages/de/product.json, keys sorted as Inlang sorts them (_ before digits before letters), named product_<page>_<element>. The i18n test fails while a key is in one language only.

bun run --cwd apps/web i18n:add product_reports_title --en "Reports" --de "Berichte" puts a message in its place in both catalogues. A product with many pages can split its messages by area, product-<area>.json (product-reports.json) in both locales, keys still product_<page>_<element> (--to product-reports). bun run check, bun run test and vite add each one to project.inlang/settings.json themselves; commit that file with it, and restart a running vite dev. Two branches that each add messages to an area of their own then merge without touching the same file.

A message of the kit’s in your own words goes in product-overrides.json, in both locales, under the kit’s key. Paraglide reads it last, so it replaces the kit’s text everywhere the kit uses that key. Keep the kit’s variables: an override may leave one out, never add one, and bun run test checks both. A release that renames or removes a key you override fails that test too, so you see it at the upgrade. The kit’s specs look for a message’s text through the message, so your wording does not fail them. Plan copy is also in the plan catalogue (@repo/billing/catalog), and i18n.spec.ts fails while the two disagree.

A refinement in your contract that tags its refusal with params: { reason } gets its copy from productIssueMessages in apps/web/src/lib/product-forms.ts, keyed by the reason:

packages/api-contract/src/reports.ts
title: z.string().refine(notBlank, {
error: 'Give the report a title',
params: { reason: 'blank_title' },
}),
// apps/web/src/lib/product-forms.ts
import type { ProductIssueMessages } from '#lib/forms.js';
import { m } from '#lib/paraglide/messages.js';
export const productIssueMessages: ProductIssueMessages = {
blank_title: () => m.product_reports_title_blank(),
};

A link in the sidebar, the mobile menu and the command palette is an entry in productNavLinks, shown after Home. A page people configure rather than use is a section of the settings dialog instead (Settings of your own). A link’s label is the message function itself, which the shell calls as it renders, so it follows the user’s language:

apps/web/src/lib/product.ts
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { m } from '#lib/paraglide/messages.js';
export const productNavLinks: ProductLink[] = [
{ href: '/app/reports', label: m.product_nav_reports, icon: FileTextIcon, keywords: ['pdf'] },
];

An action, or an entry that depends on the page, is a command: productCommands(context) in apps/web/src/lib/product-commands.ts returns groups, which the palette shows after its navigation. context has the page’s address, parameters and data. It also names the active organization. Read what a page has on screen (a selection) from a .svelte.ts module of yours. A label is the message itself, as for a link, and must not repeat another entry’s text: the palette searches by it. A command runs once the palette has closed.

apps/web/src/lib/product-commands.ts
import DownloadIcon from '@lucide/svelte/icons/download';
import type { ProductCommandContext, ProductCommandGroup } from '#lib/commands.js';
import { m } from '#lib/paraglide/messages.js';
export function productCommands({ params }: ProductCommandContext): ProductCommandGroup[] {
const report = params.report;
if (!report) return [];
return [
{
heading: m.product_reports_title,
commands: [
{
id: 'export',
label: m.product_reports_export,
icon: DownloadIcon,
shortcut: 'E',
run: () => exportReport(report),
},
],
},
];
}

A step of your own when someone signs out of this browser, such as revoking a token you issued, is productOnSignOut() in apps/web/src/lib/product-sign-out.ts (App shell).

Add the page’s first path segment (reports) to productReservedWorkspaceSlugs, or a workspace called reports loses its overview: SvelteKit opens your page at that address.

A page of yours that people configure rather than use, such as what your product connects to or its defaults, is a section of the settings dialog, not a link in the navigation. List it in productSettingsSections in apps/web/src/lib/product-settings.ts. The dialog lists your sections after the kit’s, in a group named after your app, and the command palette lists them under Settings, found by their label and their keywords:

apps/web/src/lib/product-settings.ts
import PlugIcon from '@lucide/svelte/icons/plug';
import { m } from '#lib/paraglide/messages.js';
import type { SettingsSection } from '#lib/settings.js';
export const productSettingsSections: SettingsSection[] = [
{
href: '/app/integrations',
label: m.product_integrations_title,
icon: PlugIcon,
keywords: ['slack', 'zapier'],
pages: {
'/(app)/app/integrations': () => import('../routes/(app)/app/integrations/+page.svelte'),
},
},
];

The section stays a route, routes/(app)/app/integrations/+page.svelte with its +page.ts, at an address of its own, so a link to it works from an email or a bookmark. A link or a goto to it from any page under /app opens it in the dialog over that page, which stays as it was, with the data its +page.ts loads, and Back closes the dialog. A visit to the address itself shows the same dialog over the shell. pages maps each route of the section, by route id, to its page component: the section’s own, and any page below it that keeps the section marked in the list, such as one entry’s page below a list of them. hidden: true leaves a section out of both lists, for a page that is only linked to. needs shows a section only to the roles that hold it ({ integration: ['read'] }); the page and the API check again.

The dialog shows the page over another one, so:

  • Read route parameters from the page’s params prop, not page.params, which holds those of the page underneath.
  • Give the route no +layout.svelte of its own: over another page the dialog renders the page alone.
  • Keep its <svelte:head><title>. Closing the dialog gives the window back the title of the page underneath.
  • Refresh what a change made here alters on the page underneath, which stays mounted with what it loaded: a query of it with queryClient.invalidateQueries, its load with invalidate(key, true) for a key that load names in depends, as the kit’s Security section does for /app’s passkeys. The true keeps page.state, which holds the dialog; without it, SvelteKit resets the state and the dialog closes. Members and Teams invalidate /app’s queries.

A page that draws its own chrome opens the dialog with getShell().openSettings(href) (App shell). Take the page out of productNavLinks if it was there. Reserve its first path segment in productReservedWorkspaceSlugs too, as for any page under /app.

A link to a workspace goes to its overview, /app/<slug>: its entry in the sidebar, its card on the home page, its entry in the command palette, and the page a new workspace opens at. A product whose workspaces are mostly one page of its own, such as their notes, can open them there instead. productWorkspaceHome in apps/web/src/lib/product-workspace-pages.ts takes the workspace’s own path, base, and returns where its links go: one of your workspace tabs (apps/web/src/lib/product-workspace.ts).

apps/web/src/lib/product-workspace-pages.ts
export function productWorkspaceHome(base: string): string {
return `${base}/notes`;
}
export const productHiddenWorkspaceTabs: readonly KitWorkspaceTab[] = ['jobs'];

productHiddenWorkspaceTabs names the kit’s workspace pages your product has no use for, documents and jobs. They leave the workspace’s tabs and, on a workspace’s page, the command palette’s shortcuts to them. Their routes stay, so an address someone kept still opens. The overview and Settings cannot be hidden. The kit’s specs that check where a workspace opens and which tabs it shows mock this file, so what you choose fails none of them.

The kit wrote one sample into each content folder in 1.1.0: a draft release note (changelog/first-release.md), a draft post (blog/welcome.md), and a splash page and a getting started guide in apps/docs/src/content/product. A draft (draft: true) shows in bun run dev:site and is left out of every build, the feed and the sitemap. Rewrite or delete the samples: no release adds or changes a file in these folders, and the kit’s own release notes stay in CHANGELOG.md.

A page you add under apps/docs/src/content/product/guides/ or reference/ appears in the docs sidebar by itself, so astro.config.mjs needs no edit. Pages are ordered by sidebar.order in their frontmatter, then by file name (Starlight’s rule), not by title: give each page an order, or name the files in the order you want. Starlight’s asides (:::note, :::tip, :::caution, :::danger) work in them. These pages, the kit’s manual, live in apps/docs/src/content/docs, which bun run dev:manual serves and no deploy publishes.

The root package.json has two version fields. version is your product’s: the kit ships 0.1.0 and never changes it again, and it is what /api/v1/health, the OpenAPI document and OpenTelemetry report. kitVersion is the kit release your repository is on, shown on /admin/system and by bun run doctor; only kit releases change it, so never edit it yourself. A pre-release of the kit sets it to 1.2.0-next.1 (UPGRADING.md, “Taking a pre-release”).

A few changes have no product file, because they change how the kit itself behaves. Keep each one small and leave the lines around it as they are, so that a release changing the same file merges cleanly or stops only on your lines:

  • Plans, prices, limits and features: the plan catalogue, packages/billing/src/catalog.ts, which the contract, /pricing and /app/billing are built from. A new limit’s counter and its labels go in your files above.
  • Better Auth’s configuration: plugins, providers and fields in packages/auth/src/server.ts.
  • The self-hosted stack: Caddy’s routes and the Compose files in infra/. Steps a deploy should run go in the hook folders above, not in deploy.sh.