Raypx

Architecture Overview

High-level system architecture and design decisions.

Raypx is built as a layered monolith. Each layer has a clear responsibility and communicates with adjacent layers through well-defined interfaces. This page describes the overall architecture and the design principles that guide it.

Layers

The system is organized into five layers, from the outside in:

+--------------------------------------------------+
|  Presentation Layer                               |
|  React 19 + Tailwind CSS 4 + shadcn/ui           |
|  TanStack Start file-based routing (SSR)          |
+--------------------------------------------------+
|  API Layer                                        |
|  oRPC routers with Zod validation                 |
|  TanStack Query for data fetching                 |
+--------------------------------------------------+
|  Middleware Layer                                  |
|  Security headers (CSP, HSTS, X-Frame-Options)    |
|  i18n locale detection and routing                |
|  LLM/Docs MDX negotiation                        |
+--------------------------------------------------+
|  Service Layer                                    |
|  Better Auth (authentication)                     |
|  @raypx/email (transactional email)               |
|  @raypx/storage (file uploads)                   |
|  @raypx/otp (one-time passwords)                  |
+--------------------------------------------------+
|  Data Layer                                       |
|  Drizzle ORM + PostgreSQL                         |
|  Schema definitions in @raypx/database            |
+--------------------------------------------------+

Presentation Layer

The presentation layer handles rendering and user interaction. TanStack Start provides server-side rendering via Nitro, which means every page is first rendered on the server and then hydrated on the client. File-based routing maps directory structure to URL paths. Layout groups (parenthesized folders) let you share UI shells without affecting the URL.

API Layer

oRPC provides the type-safe RPC layer. Each router defines procedures with input validation via Zod. On the server, oRPC calls bypass HTTP entirely — the client invokes procedures as direct function calls. On the browser, calls go through fetch to /api/rpc. TanStack Query wraps these calls with caching, refetching, and loading states.

Middleware Layer

Three middleware functions run on every request in sequence: security headers, i18n locale detection, and docs MDX negotiation. See Request Lifecycle for the full details.

Service Layer

Business logic lives in internal packages. @raypx/auth wraps Better Auth with configuration and plugins. @raypx/email handles transactional email through Resend with React Email templates. @raypx/storage abstracts file uploads behind a driver interface (local disk or S3). @raypx/otp manages one-time password generation and verification.

Data Layer

Drizzle ORM maps TypeScript schemas to PostgreSQL tables. All schema definitions live in @raypx/database/src/schema/. The ORM client is created lazily in src/lib/db.server.ts to avoid importing it at module scope, which would break in edge environments that do not support long-lived connections.

Design Principles

End-to-End Type Safety

Types flow from the database schema through API procedures to React components without manual synchronization. Drizzle infers column types from the schema definition. oRPC infers procedure input and output types from Zod validators. TanStack Query derives hook signatures from the oRPC client. A change to the database schema propagates as a type error all the way to the component if the API procedure is not updated.

Server-Side Rendering with Streaming

TanStack Start renders every route on the server. React 19 streaming means the server can send HTML incrementally while data is still loading. This gives users a fast first paint even when downstream services are slow.

Lazy Initialization

Server-only modules like the database client and auth instance are initialized inside functions rather than at module scope. This pattern avoids importing Node.js-specific code in environments that do not support it (like Vite's client bundle or edge workers):

// src/lib/db.server.ts
import { drizzle } from "drizzle-orm/node-postgres"

let _db: ReturnType<typeof drizzle> | null = null

export function getDb() {
  if (!_db) {
    _db = drizzle(process.env.DATABASE_URL!)
  }
  return _db
}

Isomorphic Functions

TanStack Start's createIsomorphicFn lets you write a single function with separate server and client implementations. The oRPC client uses this pattern: on the server it calls procedures directly, on the client it sends HTTP requests:

const getORPCClient = createIsomorphicFn()
  .server(() => createRouterClient(appRouter, { context: () => createContext(...) }))
  .client(() => createORPCClient(new RPCLink({ url: "/api/rpc" })))

Monorepo with Shared Catalog

pnpm workspaces and Turborepo manage 11 internal packages plus the root application. The pnpm-workspace.yaml catalog pins shared dependency versions (React, Zod, oRPC, etc.) so every package uses the same major version without manual coordination.

Data Flow

Here is the path a typical data request takes:

  1. User clicks a button in the browser.
  2. The component calls an oRPC procedure through the TanStack Query hook.
  3. On the client, the oRPC client serializes the request and sends it to /api/rpc.
  4. The Nitro server receives the request and runs it through the middleware chain.
  5. The oRPC handler validates input with Zod, executes the procedure, and returns the result.
  6. The procedure calls Drizzle to query PostgreSQL.
  7. The result flows back through the same path: database, procedure, HTTP response, oRPC client, TanStack Query cache, React component.

On the server (during SSR), step 3 is replaced with a direct procedure call that bypasses HTTP entirely.

Next Steps

On this page