.planning/codebase/ARCHITECTURE.md
Analysis Date: 2026-05-11
Reactive Resume is a single full-stack web application running as one Node.js process on port 3000. The web app is a TanStack Start application (Vite + React 19 + Nitro server) packaged in a pnpm/Turborepo monorepo. All API surface area, server-side rendering, file uploads, OAuth, OpenAPI, MCP, and PWA assets are served from the same process. Internal packages are consumed as TypeScript source through package.json exports maps that point directly at src files; there is no per-package dist output to depend on.
┌────────────────────────────────────────────────────────────────────────────┐
│ Browser (React 19) │
│ TanStack Router · TanStack Query · Zustand stores · React PDF view │
│ `apps/web/src/router.tsx` · `apps/web/src/routes/__root.tsx` │
└──────────┬─────────────────────────────────────────────────────┬───────────┘
│ HTTP / SSR hydration │ /api/rpc (RPCLink)
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Nitro server entry (TanStack Start) │
│ `apps/web/src/server.ts` · Nitro plugins (`apps/web/plugins/`) │
├──────────────────────────┬─────────────────────────────────────────────────┤
│ File-based routes │ Route `server.handlers` blocks │
│ `apps/web/src/routes/*` │ `api/rpc.$.ts` · `api/auth.$.ts` · `api/health.ts`│
│ │ `api/openapi.$.ts` · `api/uploads/$userId.$.ts`│
│ │ `mcp/index.ts` · `[.]well-known/*` · `schema.json.ts`│
└──────────┬───────────────┴──────────────────────────────┬─────────────────┘
│ in-process router client │ HTTP handler
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ oRPC routers │
│ `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts` │
│ Procedures: `publicProcedure` / `protectedProcedure` │
│ Middleware: `packages/api/src/middleware/rate-limit/index.ts` │
└──────────┬─────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Services & helpers │
│ `packages/api/src/services/{resume,ai,auth,storage,flags,statistics}.ts` │
│ `packages/api/src/helpers/{resume-access,resume-access-policy}.ts` │
│ `packages/api/src/services/resume-events.ts` (Postgres LISTEN/NOTIFY) │
│ `packages/auth/src/config.ts` (Better Auth) │
└──────────┬──────────────────────────────────────┬──────────────────────────┘
│ Drizzle ORM │ S3 / local FS
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ PostgreSQL (via `pg`) │ │ Storage (S3 or `<workspace>/data`)│
│ `packages/db/src/client.ts` │ │ `packages/api/src/services/storage.ts`│
│ schema: `packages/db/src/schema/*`│ │ served by `routes/uploads/$userId.$.tsx`│
│ migrations: `migrations/` │ └──────────────────────────────────┘
└──────────────────────────────────┘
| Component | Responsibility | File |
|---|---|---|
| Web app shell | TanStack Start app, route tree, SSR/CSR boundary, PWA, builder UI | apps/web/src/router.tsx, apps/web/src/routes/__root.tsx |
| Server entry | Nitro fetch handler wrapping react-start/server-entry | apps/web/src/server.ts |
| Migration plugin | Walks up to repo root, runs Drizzle migrations on boot | apps/web/plugins/1.migrate.ts |
| Storage plugin | Validates <workspace>/data writability when S3 is unused | apps/web/plugins/2.storage.ts |
| oRPC router root | Aggregates all sub-routers exposed at /api/rpc and OpenAPI | packages/api/src/routers/index.ts |
| oRPC context | Header-based auth resolution, publicProcedure/protectedProcedure | packages/api/src/context.ts |
| Resume service | CRUD, patch (RFC 6902), password, lock, statistics, analysis, events | packages/api/src/services/resume.ts |
| Storage service | S3 + local FS abstraction, image processing via sharp | packages/api/src/services/storage.ts |
| Resume access policy | Owner/viewer/redaction rules for getBySlug and statistics | packages/api/src/helpers/resume-access-policy.ts |
| Resume events | Postgres LISTEN/NOTIFY channel for live updates | packages/api/src/services/resume-events.ts |
| Auth | Better Auth config, OAuth provider, passkey, 2FA, API keys, JWKS | packages/auth/src/config.ts |
| Database | Drizzle client singleton, schema, generated migrations | packages/db/src/client.ts, packages/db/src/schema/*.ts, migrations/ |
| Schema | Zod resume/page/template models shared across web + API + PDF + MCP | packages/schema/src/resume/data.ts, packages/schema/src/templates.ts |
| PDF rendering | React PDF Document, font registration, 14 template implementations | packages/pdf/src/document.tsx, packages/pdf/src/templates/index.ts, packages/pdf/src/hooks/use-register-fonts.ts |
| Shared UI | Base UI / shadcn-style component library and hooks | packages/ui/src/components/*.tsx, packages/ui/src/hooks/*.tsx |
| MCP server | Model Context Protocol server backed by oRPC routers | apps/web/src/routes/mcp/index.ts, apps/web/src/routes/mcp/-helpers/* |
Overall: Modular monolith. One deployable web app + a constellation of source-only TypeScript packages communicating through typed package.json exports. Browser ↔ server communication uses oRPC (typed RPC with REST/OpenAPI generation) instead of REST/tRPC. SSR and client share the same router/queryClient via TanStack Start.
Key Characteristics:
dist build) keep types end-to-end./api/rpc and as an in-process createRouterClient for SSR/server functions, with identical types on both paths..browser.tsx files and ssr: false / ssr: "data-only" route opts to keep SSR bundles small and safe.pg_notify channel resume_updated for live builder sync).context rather than fetched per-route.Routes (apps/web/src/routes/):
server.handlers blocks that act as HTTP endpoints.apps/web/src/routes/packages/api/routers (mounted at /api/rpc), packages/auth/config (mounted at /api/auth), packages/db/client, packages/api/services/*.apps/web/src/routeTree.gen.ts).oRPC API layer (packages/api/src/routers/):
packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts, aggregated in packages/api/src/routers/index.ts.packages/api/src/context.ts, packages/api/src/dto/*, packages/api/src/services/*.apps/web/src/routes/api/rpc.$.ts (RPCHandler), apps/web/src/routes/api/openapi.$.ts (OpenAPIHandler), apps/web/src/libs/orpc/client.ts (isomorphic client), MCP tools in apps/web/src/routes/mcp/-helpers/tools.ts.Services / business logic (packages/api/src/services/):
packages/api/src/services/*.tsuserId-style inputs. No request/response coupling.packages/db/client, packages/db/schema, packages/auth/config, packages/schema/resume/*, packages/utils/*, packages/email/transport, AI SDKs./api/health route.Persistence (packages/db/):
packages/db/src/client.ts, packages/db/src/schema/{auth,resume,index}.ts, packages/db/src/relations.ts.Pool singleton (stored on globalThis.__pool to survive HMR), drizzle() client, table definitions, relations.pg, drizzle-orm, packages/env/server.drizzle-kit into the repo-root migrations/ directory (see packages/db/drizzle.config.ts) and applied at startup by apps/web/plugins/1.migrate.ts.Auth (packages/auth/):
packages/auth/src/config.ts, packages/auth/src/functions.ts, packages/auth/src/types.ts.apps/web/src/routes/api/auth.$.ts (delegates request → auth.handler(request) after sanitizing OAuth params).apps/web/src/routes/mcp/index.ts via verifyOAuthToken.PDF rendering (packages/pdf/):
packages/pdf/src/document.tsx mounts getTemplatePage(template); templates live under packages/pdf/src/templates/<name>/.packages/pdf/src/templates/shared/{filtering,rich-text,sections,primitives,picture,page-size,columns}.ts(x).packages/pdf/src/hooks/use-register-fonts.ts owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation.apps/web/src/components/resume/preview*.tsx, pdf-canvas.tsx), public resume page (apps/web/src/routes/$username/$slug.tsx), and the OpenAPI /resumes/{id}/download procedure (apps/web/src/routes/api/-helpers/resume-pdf.ts).Shared schemas (packages/schema/):
packages/schema/src/resume/{data,default,sample,analysis}.ts, packages/schema/src/templates.ts, packages/schema/src/page.ts, packages/schema/src/icons.ts.packages/api/src/dto/resume.ts), DB column typing (packages/db/src/schema/resume.ts), import package, PDF rendering, web forms, MCP tool descriptions, and the public JSON schema at /schema.json.Shared UI (packages/ui/):
apps/web.packages/ui/src/components/*.tsx, packages/ui/src/hooks/*.tsx.dialog, dropdown-menu, command, resizable, sonner, tooltip, form, direction.packages/ui/src/styles/globals.css (Tailwind v4 entry).Support packages:
packages/utils — small focused helpers (color, date, field, file, html, level, locale, network-icons, rate-limit, sanitize, string, style, url, plus Node-only monorepo.node, url-security.node, and resume/{docx,patch}).packages/env — @t3-oss/env-core server schema; dotenv loads the repo-root .env (packages/env/src/server.ts).packages/email — nodemailer transport + react-email templates (packages/email/src/transport.ts, packages/email/src/templates/*.tsx).packages/import — converters for JSON Resume, Reactive Resume v3/v4 JSON (packages/import/src/*.tsx).packages/ai — Zustand store, AI prompts (packages/ai/src/prompts/*.md), patch-resume tool, sanitize/extraction helpers consumed by the AI router.packages/fonts — generated Google Fonts metadata (packages/fonts/src/webfontlist.json, packages/fonts/src/index.ts).packages/scripts — repo-level scripts (packages/scripts/database/reset.ts, packages/scripts/fonts/generate.ts).packages/config — shared TypeScript/Vitest base configs (packages/config/tsconfig.base.json, packages/config/vitest.config.ts).packages/runtime-externals — declares bcrypt, sharp, @aws-sdk/client-s3 so they remain runtime-only (externalized in apps/web/vite.config.ts).orpc.resume.getById.queryOptions(...) from apps/web/src/libs/orpc/client.ts:84 to fetch data.apps/web/src/libs/orpc/client.ts:28-47) creates an RPCLink pointing at ${window.location.origin}/api/rpc with credentials: "include" and a BatchLinkPlugin.apps/web/src/routes/api/rpc.$.ts, where RPCHandler (line 9) dispatches with BatchHandlerPlugin, RequestHeadersPlugin, and StrictGetMethodPlugin.publicProcedure (packages/api/src/context.ts:79) resolves the user from headers (x-api-key → bearer JWT via JWKS → Better Auth session cookie).protectedProcedure (packages/api/src/context.ts:90) rejects unauthenticated callers with ORPCError("UNAUTHORIZED").packages/api/src/routers/resume.ts calls into packages/api/src/services/resume.ts, which queries Drizzle (packages/db/src/client.ts:32).apps/web/src/libs/orpc/client.ts:13-27 short-circuits the HTTP path via createRouterClient(router, { context: async () => ({ locale, reqHeaders }) }).publicProcedure/protectedProcedure middleware runs in-process — no socket hop — but still resolves auth from the original request headers via getRequestHeaders() from @tanstack/react-start/server.apps/web/src/routes/builder/$resumeId/route.tsx:39-44) populate the query cache via context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions(...)).subscribe procedure in packages/api/src/routers/resume.ts:76 returns an async generator.packages/api/src/services/resume-events.ts:37 calls pg_notify('resume_updated', JSON.stringify(event)).resume.updated SSE-style events via oRPC streaming (apps/web/src/libs/orpc/client.ts:51-82, streamClient).useResumeUpdateSubscription (apps/web/src/components/resume/builder-resume-draft.ts).GET /api/openapi/resumes/{id}/download (oRPC OpenAPI handler at apps/web/src/routes/api/openapi.$.ts).downloadResumePdfProcedure in apps/web/src/routes/api/-helpers/resume-pdf.ts loads the resume, renders ResumeDocument from packages/pdf/src/document.tsx, persists to storage via getStorageService(), and returns/streams the PDF.apps/web/src/routes/$username/$slug.tsx uses ssr: "data-only" — server fetches resume.getBySlug but renders the React PDF preview only on the client.packages/api/src/helpers/resume-access-policy.ts enforces visibility, redacts non-public fields, and throws NEED_PASSWORD for password-protected resumes (the route then redirects to /auth/resume-password).State Management:
apps/web/src/libs/query/client.ts) wrapped by oRPC's createTanstackQueryUtils.apps/web/src/routes/builder/$resumeId/-store/{section,sidebar}.ts, apps/web/src/dialogs/store.ts, apps/web/src/components/command-palette/store.ts, apps/web/src/components/resume/builder-resume-draft.ts.theme, locale, session, flags, queryClient, orpc are computed once in apps/web/src/router.tsx (and again in the root route beforeLoad) and reused by descendants.BUILDER_LAYOUT_COOKIE_NAME), theme, and locale are persisted via getCookie/setCookie server functions.oRPC procedures (os.$context<ORPCContext>()):
publicProcedure and protectedProcedure in packages/api/src/context.ts:79/:90.procedure.route({...openapi metadata}).input(zodSchema).use(rateLimitMiddleware).output(zodSchema).handler(async ({ context, input }) => ...).Drizzle tables:
.jsonb() columns are typed against Zod-derived TypeScript (ResumeData, StoredResumeAnalysis).packages/db/src/schema/resume.ts (resume, resumeStatistics, resumeAnalysis), packages/db/src/schema/auth.ts (Better Auth tables).pg.pgTable("name", { ... }, (t) => [pg.index().on(...), pg.unique().on(...)]).Template pages (React PDF):
TemplatePage component per visual template, mapped by name in packages/pdf/src/templates/index.ts.packages/pdf/src/templates/azurill/AzurillPage.tsx, packages/pdf/src/templates/onyx/OnyxPage.tsx.(props: { page: LayoutPage; pageIndex: number }) => JSX, consuming RenderProvider from packages/pdf/src/context.tsx.Base UI components:
packages/ui/src/components/dialog.tsx, packages/ui/src/components/command.tsx, packages/ui/src/components/resizable.tsx.import { Dialog } from "@reactive-resume/ui/components/dialog";.TanStack Router file routes:
server.handlers blocks per file.apps/web/src/routes/builder/$resumeId/route.tsx, apps/web/src/routes/api/rpc.$.ts.export const Route = createFileRoute("/path")({ component, loader, beforeLoad, server: { handlers: { GET, POST } }, ssr });.Vite + Nitro build entry:
apps/web/vite.config.tspnpm dev, pnpm build. Wires TanStack Start, Tailwind v4, Lingui (i18n), Nitro plugins, and the Vite PWA plugin.bcrypt, sharp, @aws-sdk/client-s3 (declared in packages/runtime-externals).Server fetch entry:
apps/web/src/server.ts@tanstack/react-start/server-entry and substitutes srvx's FastResponse as the global Response.Nitro startup plugins:
apps/web/plugins/1.migrate.ts — resolves the repo-root migrations/ folder, opens its own pg.Pool, runs Drizzle migrations on boot, then closes the pool.apps/web/plugins/2.storage.ts — when S3 env vars are absent, ensures the local storage directory is writable before serving requests.Router entry:
apps/web/src/router.tsxqueryClient, loads theme/locale/session/flags in parallel, creates the TanStack Router with router context, registers SSR query integration.Root route:
apps/web/src/routes/__root.tsxI18nProvider, ThemeProvider, HotkeysProvider, DirectionProvider, TooltipProvider, ConfirmDialogProvider, PromptDialogProvider), PWA head/scripts, DialogManager, CommandPalette, Toaster.HTTP endpoints (route server.handlers):
apps/web/src/routes/api/rpc.$.ts — /api/rpc/* oRPC handler (browser RPC).apps/web/src/routes/api/auth.$.ts — /api/auth/* Better Auth handler (with OAuth payload sanitization).apps/web/src/routes/api/health.ts — /api/health JSON probe (db + storage with timeouts).apps/web/src/routes/api/openapi.$.ts — /api/openapi/* OpenAPI handler + spec.apps/web/src/routes/api/uploads/$userId.$.ts and apps/web/src/routes/uploads/$userId.$.tsx — signed/etagged static file serving from storage.apps/web/src/routes/mcp/index.ts — /mcp Model Context Protocol server (OAuth-protected).apps/web/src/routes/[.]well-known/* — /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, /.well-known/openid-configuration, /.well-known/mcp discovery documents.apps/web/src/routes/schema[.]json.ts — /schema.json public JSON Schema for ResumeData.Builder + public resume entry points:
apps/web/src/routes/builder/$resumeId/route.tsx — authenticated builder shell (header + resizable left/right sidebars + artboard outlet + assistant).apps/web/src/routes/builder/$resumeId/index.tsx — ssr: false; lazy-loaded PreviewPage running React PDF/canvas only in the browser.apps/web/src/routes/$username/$slug.tsx — ssr: "data-only"; public/shared resume view (with password gating via NEED_PASSWORD ORPCError).package.json exports point at src/*.ts(x); do not assume dist output exists. The Vite build externalizes bcrypt, sharp, @aws-sdk/client-s3 (apps/web/vite.config.ts:55).globalThis for DB pool: packages/db/src/client.ts:8-11 caches the pg.Pool and Drizzle client on globalThis.__pool / globalThis.__drizzle to survive HMR reloads.@react-pdf/renderer canvas, and the builder preview must stay off the SSR path. Use .browser.tsx suffix files and ssr: false (builder preview) or ssr: "data-only" (public resume).apps/web/src/routeTree.gen.ts is regenerated by TanStack Router tooling; never edit by hand.drizzle-kit writes to ../../migrations from packages/db/drizzle.config.ts, so all migration directories live at the repo root, not inside the package.pnpm db:migrate / pnpm db:generate require DATABASE_URL exported in the shell; only the runtime Node code loads .env via packages/env/src/server.ts.packages/api/src/middleware/rate-limit/index.ts:5 and the Better Auth config gate rate limits on process.env.NODE_ENV === "production".verifyOAuthToken (packages/auth/src/config.ts:40) only accepts JWTs whose aud matches ${APP_URL} (with/without trailing slash) or ${APP_URL}/mcp.pg.Pool (packages/db/src/client.ts:13); scaling beyond one process requires replacing pg_notify with a broker.What happens: Components separately calling getSession(), getTheme(), or getLocale() instead of reading them from TanStack Router context.
Why it's wrong: The root route's beforeLoad already loads these in parallel (apps/web/src/routes/__root.tsx:82-93) and exposes them via Route.useRouteContext(); duplicating the calls causes extra round-trips during SSR and triggers locale reloads.
Do this instead: Use Route.useRouteContext() or read from a parent route loader, as apps/web/src/routes/__root.tsx:101 does for theme/locale.
What happens: Importing @reactive-resume/db/client or @reactive-resume/api/services/storage from a non-route component.
Why it's wrong: Pulls pg, sharp, bcrypt, @aws-sdk/client-s3 into the client bundle (which Vite externalizes — the build will fail or break at runtime).
Do this instead: Call the corresponding oRPC procedure from packages/api/src/routers/*. Server-only imports belong inside route server.handlers blocks or .server.tsx files like apps/web/src/libs/resume/pdf-document.server.tsx.
What happens: Importing @react-pdf/renderer or PDF.js from a file that participates in SSR.
Why it's wrong: These libraries crash under Node SSR (canvas/DOM dependencies).
Do this instead: Keep browser code in *.browser.tsx / pdf-canvas.tsx and gate with ssr: false (e.g. apps/web/src/routes/builder/$resumeId/index.tsx:6) or ssr: "data-only" (e.g. apps/web/src/routes/$username/$slug.tsx:40).
What happens: Reading resume rows directly from Drizzle in a public procedure without applying redaction.
Why it's wrong: Leaks owner-only fields (password hash, private flags, statistics) and breaks the password-gate flow that depends on NEED_PASSWORD errors.
Do this instead: Route through packages/api/src/helpers/resume-access-policy.ts (assertCanView, redactResumeForViewer, shouldCountForStatistics) as packages/api/src/services/resume.ts does.
What happens: Modifying apps/web/src/routeTree.gen.ts or migration SQL after Drizzle writes it.
Why it's wrong: Edits are overwritten on the next tanstack-router regen or migration; data drift between snapshots and SQL breaks future migrations.
Do this instead: Add a route file under apps/web/src/routes/, or change the Drizzle schema in packages/db/src/schema/*.ts and run pnpm db:generate.
Strategy: Typed errors via ORPCError codes; HTTP responses for low-level handlers; route-level defaultErrorComponent and onError for UI fallbacks.
Patterns:
ORPCError("UNAUTHORIZED"|"NOT_FOUND"|"NEED_PASSWORD"|...) or use .errors({ ... }) to declare typed application errors (e.g. RESUME_SLUG_ALREADY_EXISTS, RESUME_VERSION_CONFLICT in packages/api/src/routers/resume.ts).packages/api/src/context.ts:14-55 swallow verification errors and log via console.warn so unauthenticated requests fall through to the next strategy.apps/web/src/routes/api/rpc.$.ts and apps/web/src/routes/api/openapi.$.ts install onError interceptors that log every server error with a tag ([oRPC Server], [OpenAPI]).onError (e.g. apps/web/src/routes/$username/$slug.tsx:24) translates NEED_PASSWORD into a redirect.apps/web/src/components/layout/error-screen.tsx, loading-screen.tsx, not-found-screen.tsx (wired in apps/web/src/router.tsx:30-32).apps/web/src/routes/api/health.ts:22-34) so a stuck dependency cannot stall the probe.Logging: console.info/console.warn/console.error with bracketed prefixes (e.g. [oRPC Server], [Healthcheck], [oRPC client]). No external log shipping is wired.
Validation: Zod 4 everywhere — Drizzle column types (packages/db/src/schema/resume.ts), oRPC input/output schemas, AI tool inputs, environment variables (packages/env/src/server.ts), and the public /schema.json route.
Authentication: Better Auth in packages/auth/src/config.ts (Drizzle adapter, email/password + OAuth + passkey + 2FA + admin + API keys + JWT + generic OAuth + dynamic client registration + custom oauthProvider for MCP). The unified resolver in packages/api/src/context.ts:64 is the only place that decides which credential wins.
Internationalization: Lingui — apps/web/lingui.config.ts, apps/web/locales/*.po, apps/web/src/libs/locale.ts, RTL toggling in __root.tsx.
Theming: apps/web/src/libs/theme.ts (cookie-backed), apps/web/src/components/theme/provider.tsx (next-themes).
Rate limiting: @orpc/experimental-ratelimit with an in-memory ratelimiter from packages/api/src/middleware/rate-limit/index.ts. Trusted IP headers come from packages/utils/src/rate-limit.ts.
Feature flags: Server-resolved at boot (packages/api/src/routers/flags.ts and packages/api/src/services/flags.ts), then carried in router context via apps/web/src/router.tsx:20.
Architecture analysis: 2026-05-11