Skip to content

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.

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.css
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.

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.

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:

  1. config/payment.ts defines channels. A channel is a named configuration like “stripe” with its API keys and settings.
  2. core/drivers/payment/stripe.ts implements the PaymentDriver interface for Stripe. It knows how to create customers, start checkouts, and handle webhooks.
  3. lib/facades/payment.ts is the facade. It picks the right driver based on the channel, manages customer syncing with the database, and exposes methods like payment.checkout() and payment.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.ts defines providers, core/drivers/email/ has Resend and SendGrid drivers, lib/facades/mailer.ts is the facade.
  • Storage - config/storage.ts defines S3 buckets, core/drivers/storage/s3.ts is the driver, lib/facades/storage.ts is the facade.
  • Rate limiting - config/rate-limit.ts defines rules, core/drivers/rate-limit/memory.ts is the driver, lib/facades/rate-limit.ts is the facade.

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
})

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.

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.

The layer boundaries are enforced by convention:

  • Routes and components never import from lib/facades/ or services/. They go through server functions.
  • Components can import shared code from lib/ root (auth-client, entitlements, utils, constants) and static data from config/.
  • 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/, and lib/facades/. Never in components or routes (except VITE_-prefixed ones).
FilePurpose
lib/facades/auth.tsBetter Auth server config, database hooks, session enrichment
lib/auth-client.tsBetter Auth client with 2FA, admin, and org plugins
lib/facades/payment.tsPayment facade (checkout, portal, plan changes)
lib/facades/mailer.tsEmail facade (template rendering, multi-channel)
lib/facades/storage.tsStorage facade (uploads, presigned URLs)
lib/entitlements.tsPlan feature checks (hasFeature, withinLimit)
lib/facades/audit.tsAudit log facade
lib/facades/rate-limit.tsRate limiter facade
config/features.tsFeature registry for plan entitlements
database/index.tsDrizzle client
router.tsxTanStack Router config