Architecture
TSKit is organized in layers. Each layer has one job and only talks to the layers below it. This page explains the reasoning behind that structure and how the pieces connect.
Directory layout
Section titled “Directory layout”src/├── routes/ # Page shells - load data, render components│ ├── _auth/ # Guest-only (login, register, forgot password)│ ├── _app/ # Authenticated (dashboard, settings, billing)│ ├── _marketing/ # Public (landing page, pricing)│ ├── admin/ # Admin dashboard│ └── api/ # Server-only handlers (auth, webhooks)├── components/ # All UI, grouped by domain│ ├── selia/ # Design system components│ ├── app/ # App shell, email verification banner, team switcher│ ├── auth/ # Login, signup, forgot/reset password forms│ ├── billing/ # Plan card, checkout, cancel, change plan│ ├── settings/ # Profile, password, 2FA, avatar, sessions│ └── shared/ # Error boundary, not-found, page header├── functions/ # Server functions (RPC boundary)├── services/ # Business logic and database queries├── validations/ # Shared Zod schemas and types├── hooks/ # React hooks├── middleware/ # Auth, org, admin, rate-limit middleware├── config/ # Named channels and static registries├── lib/ # App-level code - facades + shared helpers│ └── facades/ # Config-to-driver wiring (never import in components)├── core/drivers/ # Portable, config-injected driver implementations├── emails/ # React Email templates (server-only)├── database/│ ├── schemas/ # Drizzle table definitions│ └── migrations/ # SQL migration files├── router.tsx├── routeTree.gen.ts # Auto-generated by TanStack (read-only)└── styles.cssLayers
Section titled “Layers”graph TD R[Routes] --> C[Components] R --> F[Server Functions] C --> F F --> S[Services] F --> Facades["Lib / Facades"] S --> DB[Database] Facades --> D[Core Drivers] F -.-> MW[Middleware]
Here is what each layer does and why it exists as a separate concern:
Routes load data and render components. They are thin on purpose. A route’s loader awaits a server function and returns its data, and the component reads it via Route.useLoaderData(). No business logic lives here. Routes are grouped by access level: _auth/ for guests, _app/ for authenticated users, admin/ for admins, and _marketing/ for public pages.
Components are all the UI, grouped by domain (auth, billing, settings, etc.). They read session data from route context and loader data via Route.useLoaderData() or getRouteApi('/path').useLoaderData(). They can import shared helpers from lib/ (like auth-client, entitlements, utils) but never from lib/facades/.
Server functions are the boundary between client and server. They are the only layer that handles middleware (auth, rate limiting), input validation (Zod), and audit logging. Everything that needs to run on the server goes through here.
Services hold business logic and database queries. They are plain functions, not classes. They don’t know anything about HTTP, sessions, or middleware.
Facades live in lib/facades/ and provide the daily-driver API for infrastructure like email, storage, payment, and rate limiting. They wire config to drivers and abstract away which provider is being used. Components and routes must never import from this directory.
Lib (shared) contains app-level code at the lib/ root - things like auth-client.ts, entitlements.ts, utils.ts, and constants.ts. Unlike facades, these are importable anywhere in the app.
Core drivers are the actual implementations (Stripe, Resend, S3, in-memory rate limiter). They know how to talk to a specific provider but nothing about the rest of the app. Config is injected.
Middleware runs before server function handlers. Each middleware validates a condition and adds data to the function context. They compose by chaining.
Why server functions exist as a separate layer
Section titled “Why server functions exist as a separate layer”You might wonder why services aren’t called directly from routes. The reason is that routes run on both client and server (for SSR), but services need to stay server-only. Server functions solve this by acting as a typed RPC boundary.
They also give you a natural place to compose cross-cutting concerns:
export const createCheckout = createServerFn({ method: 'POST' }) .middleware([defaultRateLimit, orgMiddleware]) .inputValidator(z.object({ planId: z.string().min(1) })) .handler(async ({ data, context }) => { await requireBillingRole(headers) const result = await payment.checkout(context.organization.id, ...) await audit.log({ action: 'billing.checkout.created', ... }) return result })In one declaration you get rate limiting, authentication, org context, input validation, the actual logic, and an audit trail. Without this layer, those concerns would be scattered across routes and services.
How data flows
Section titled “How data flows”A concrete example: a user clicking “Subscribe” on a plan.
sequenceDiagram participant Route as Route / Component participant Fn as Server Function participant MW as Middleware participant Facade as Facade participant DB as Database participant Ext as Stripe Route->>Fn: createCheckout(planId) Fn->>MW: Rate limit check MW->>MW: Auth check MW->>MW: Org context MW-->>Fn: context with user + org Fn->>Facade: payment.checkout(orgId, planId, ...) Facade->>DB: Find or create customer Facade->>Ext: Create checkout session Ext-->>Facade: Checkout URL Facade-->>Fn: Return URL Fn->>DB: audit.log(...) Fn-->>Route: Redirect to Stripe
Data always flows down through the layers. The route never talks to the database. The service never checks authentication. Each layer trusts the one above it to have done its job.
The driver system
Section titled “The driver system”Storage, email, payment, and rate limiting all follow the same pattern: config defines what to use, a driver implements it, and a facade gives the rest of the app a clean API.
graph LR Config["config/payment.ts"] --> Factory["Driver factory"] Factory --> Driver["StripePaymentDriver"] Facade["lib/facades/payment.ts"] --> Factory App["Services / Functions"] --> Facade
Take payment as an example:
config/payment.tsdefines channels. A channel is a named configuration like “stripe” with its API keys and settings.core/drivers/payment/stripe.tsimplements thePaymentDriverinterface for Stripe. It knows how to create customers, start checkouts, and handle webhooks.lib/facades/payment.tsis the facade. It picks the right driver based on the channel, manages customer syncing with the database, and exposes methods likepayment.checkout()andpayment.portal().
The app code only ever calls the facade. If you want to swap Stripe for a different provider, you write a new driver and update the config. Nothing else changes.
This same pattern applies to:
- Email -
config/mail.tsdefines providers,core/drivers/email/has Resend and SendGrid drivers,lib/facades/mailer.tsis the facade. - Storage -
config/storage.tsdefines S3 buckets,core/drivers/storage/s3.tsis the driver,lib/facades/storage.tsis the facade. - Rate limiting -
config/rate-limit.tsdefines rules,core/drivers/rate-limit/memory.tsis the driver,lib/facades/rate-limit.tsis the facade.
How middleware composes
Section titled “How middleware composes”Middleware modules chain together. Each one can depend on another, and the context accumulates as it flows through the chain.
graph TD RL[Rate Limit] --> Auth[Auth] Auth --> Org[Org] Auth --> EV[Email Verified] Auth --> Admin[Admin] Org --> Sub[Subscribed]
When you attach subscribedMiddleware to a server function, you don’t need to also attach orgMiddleware or authMiddleware. The subscribed middleware chains org internally, and org chains auth internally. You only reference the outermost middleware you need.
By the time your handler runs, the context contains everything the chain has collected:
// subscribedMiddleware gives you all three.middleware([subscribedMiddleware]).handler(async ({ context }) => { context.user // from authMiddleware context.organization // from orgMiddleware context.subscription // from subscribedMiddleware})Session and context flow
Section titled “Session and context flow”The session is loaded once at the root of the app and flows down to every page through route context.
__root.tsx calls getSession(), getActiveOrganization(), and getUserSettings() in its beforeLoad hook. The results are passed as route context. Layout routes (_app, _auth, admin) check access in their own beforeLoad - for example, _app redirects to /login if there is no session. Components read session data through Route.useRouteContext().
This means no page or component needs to fetch the session on its own. It is always available from context.
Webhooks
Section titled “Webhooks”Incoming webhooks live in routes/api/webhooks/<provider>.ts. They are server-only API routes, so they can call services directly without going through server functions. Each webhook handler validates the signature, parses the event, and delegates to the appropriate service. Responses use apiError() and apiSuccess() from lib/api-response.ts.
Import rules
Section titled “Import rules”The layer boundaries are enforced by convention:
- Routes and components never import from
lib/facades/orservices/. They go through server functions. - Components can import shared code from
lib/root (auth-client, entitlements, utils, constants) and static data fromconfig/. - Validations (
validations/) are a leaf dependency. They import nothing from the app, and any layer can import them. - API routes (
routes/api/) are the exception. They run server-only, so they can call services directly without going through server functions. - Environment variables are read in
config/,database/, andlib/facades/. Never in components or routes (exceptVITE_-prefixed ones).
Key files
Section titled “Key files”| File | Purpose |
|---|---|
lib/facades/auth.ts | Better Auth server config, database hooks, session enrichment |
lib/auth-client.ts | Better Auth client with 2FA, admin, and org plugins |
lib/facades/payment.ts | Payment facade (checkout, portal, plan changes) |
lib/facades/mailer.ts | Email facade (template rendering, multi-channel) |
lib/facades/storage.ts | Storage facade (uploads, presigned URLs) |
lib/entitlements.ts | Plan feature checks (hasFeature, withinLimit) |
lib/facades/audit.ts | Audit log facade |
lib/facades/rate-limit.ts | Rate limiter facade |
config/features.ts | Feature registry for plan entitlements |
database/index.ts | Drizzle client |
router.tsx | TanStack Router config |