Architecture
How a request moves through ShipKit, how the code is split into core and feature slices, and the bundle rules that keep the site fast.
ShipKit is one Cloudflare Worker. The same Worker serves the marketing site, the signed-in app, the admin console, the webhooks and the public API, and it runs the nightly cron. This page is the map: what runs first, where code lives, and the handful of rules that keep the pieces apart.
The request path
Every request enters through three files, in this order.
| File | What it does |
|---|---|
src/server.ts | The Worker entry. Refuses cross-site calls to server functions, wraps TanStack Start's handler in paraglideMiddleware so server code knows the request's locale, adds the security headers to every response, and exports scheduled for the cron. |
src/start.ts | Global TanStack Start config (defaultSsr: true). The place for global request or function middleware. |
src/router.tsx | Creates the router and wires React Query into SSR. Its rewrite strips the locale from the URL on the way in and adds it back on the way out, so /zh/pricing matches the same route file as /pricing. |
Because of that rewrite, no route file ever deals with a locale segment. English is unprefixed and Chinese lives under /zh; see Internationalization.
src/server.ts also imports the settings, permission and job registries at the top, so they exist before anything asks for them, whether the Worker woke up for a page, a webhook or a cron.
Route groups
Routes are files under src/routes/. The underscore-prefixed folders are layout groups: they share a layout and guards but add nothing to the URL.
| Group | URLs | Rendering |
|---|---|---|
_marketing/ | /, /pricing, /blog, /docs, /legal/* | Server-rendered, and prerendered to static HTML at build time (the prerender option in vite.config.ts) |
_auth/ | /login | Server-rendered |
_app/ | /dashboard, /billing, /credits, /files, /settings, /api-keys, /affiliate, /admin/* | ssr: 'data-only': loaders run on the server, the page renders in the browser |
api/ | /api/* | Server routes only, no UI |
The _app shell in src/routes/_app.tsx is the session guard: its beforeLoad loads the session and redirects to /login without one. The user's pages and the admin console share that shell; the sidebar switches between the two areas. The console is covered in Admin console.
A few routes sit outside the groups: /r/$code (the affiliate link, a server route that sets a cookie and redirects), /waitlist, and the dev-only /blocks preview of every landing-page block.
Core and features
Code under src/ is split into a shared kernel and a set of vertical slices.
src/core/ is what every feature relies on: the database client (db/), server plumbing (server/: bindings, server-function middleware, the external API helpers, rate limiting, security headers), the payment event layer, permissions (authz/), operator settings, email sending, and a set of registries described below. Core does not know which optional features exist.
src/features/<name>/ is one product capability, end to end. A feature owns its tables, its server code and its UI:
src/features/credits/
schema.ts Drizzle tables
queries.ts React Query options the routes and components use
permissions.ts the admin permissions it declares
settings.ts operator-editable values it registers
server/ server functions, event handlers, jobs, API queries
components/ its UI
The bundled features are auth, billing, credits, files, content, audit, email-log, feedback, notifications, waitlist, analytics, affiliate and demo. Features do not import each other; anything they share goes through core/. That is what makes most of them deletable in minutes: see Deleting features, and Adding features for building a new one the same way.
src/config/ holds the things you edit to make the template yours: branding, plans, the landing page, themes. See Configuration.
Registries: how core calls features without importing them
Core often needs to reach into features. A payment arrives and billing, credits and affiliate each want to react; the nightly cron must run every feature's maintenance job. Core cannot import those features, or deleting one would break core.
The answer is the same small pattern throughout src/core/:
events.tsholds a registry and a function to add to it, such asonPaymentEvent(handler),registerJob(job)oronAudit(sink).- Each feature registers itself from its own file, for example
src/features/credits/server/events.tscallsonPaymentEvent(...). handlers.tsin the core folder has one import line per feature, which runs those registrations.
// src/core/jobs/handlers.ts
import '@/core/payment/jobs'
import '@/features/auth/server/jobs'
import '@/features/billing/server/jobs'
import '@/features/credits/server/jobs'
// ...
Deleting a feature removes its line, and the registry has one fewer entry. The same shape is used for payment events, cron jobs, admin overview stats, account deletion and export hooks, audit sinks, email delivery logging, in-app notifications, product analytics, operator settings, permissions and the sections of the admin user page.
Some registries load themselves. audit(), notify() and track() are called from many places, so each imports its own handlers.ts on first use; callers never import it, and with the feature deleted the call is a no-op.
Bundle rules
Two build facts shape how code is written here.
Only a route's component is code-split. A route's loader, validateSearch and other options, plus every top-level import in the route file, end up in the main bundle that the landing page downloads. So a route that needs a registry imports it inside the loader:
loader: async ({ context }) => {
await import('@/core/authz/handlers')
return context.queryClient.ensureQueryData(adminRolesQuery)
},
Some files are bundled for the browser even though they look server-side. A feature's server/fns.ts is one: server functions leave a client stub behind, and their input validators stay in the client bundle. The build only strips what sits inside a server function's handler. This leads to three rules:
cloudflare:workersis imported in exactly one file,src/core/server/cf.ts. Everything else gets bindings fromgetBindings(). An import anywhere else breaks the client bundle.- Constants that client code needs (statuses, defaults) live in a feature's
config.ts, which imports nothing server-only, never inschema.ts. - A feature's
settings.tsandpermissions.tsare loaded by the browser too, so they must stay client-safe. A test (src/core/settings/handlers.test.ts) enforces it for settings.
These rules are also written into CLAUDE.md, so an AI agent working in the repo follows them; see AI agents.
The data channel at a glance
Pages read and write data one way:
route loader -> queryClient.ensureQueryData(query) -> server function -> Drizzle -> D1
The loader warms the React Query cache, the component reads the same query, and mutations call a server function and invalidate what they changed. Server functions start from authedFn or adminFn, which do the session and access checks for you. API routes exist only for callers that are not your own pages: sign-in callbacks, payment webhooks, file streams and the external API.
Server functions covers this channel in detail, Database covers D1 and Drizzle, and Permissions covers who may call what.