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.

FileWhat it does
src/server.tsThe 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.tsGlobal TanStack Start config (defaultSsr: true). The place for global request or function middleware.
src/router.tsxCreates 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.

GroupURLsRendering
_marketing//, /pricing, /blog, /docs, /legal/*Server-rendered, and prerendered to static HTML at build time (the prerender option in vite.config.ts)
_auth//loginServer-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.ts holds a registry and a function to add to it, such as onPaymentEvent(handler), registerJob(job) or onAudit(sink).
  • Each feature registers itself from its own file, for example src/features/credits/server/events.ts calls onPaymentEvent(...).
  • handlers.ts in 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:workers is imported in exactly one file, src/core/server/cf.ts. Everything else gets bindings from getBindings(). 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 in schema.ts.
  • A feature's settings.ts and permissions.ts are 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.