All pages

Architecture

The repository is four packages under one pnpm workspace.

Package What it is Runs on
apps/web TanStack Start: marketing, docs, blog, checkout, dashboard, admin Vercel or Railway
apps/api Express 5 under tsx Railway, or anything that runs a long-lived Node process
packages/shared The wire contract both apps agree on, plus the Drizzle schema and client neither, it is imported
packages/tsconfig The three TypeScript bases neither

There is no fifth package and no src/server folder inside the web app. Where a piece of code lives is decided by one question, and the answer to that question is the whole architecture: business routes needing Stripe, the database or integration credentials belong to apps/api. Web also configures a Clerk secret for the web SDK's server-side identity handling.

Why the api exists at all

TanStack Start can hold server code. Server routes and functions run on a server, read process.env, and can talk to Postgres, so a second app looks like a deployment you were not obliged to have.

The reason to have it is that "runs on a server" and "is the only thing holding secrets" are different properties, and only the second one is checkable. In a single full-stack web app, every server module is a candidate for holding a Stripe key, and every server/client boundary has to keep it out of the browser bundle. Splitting the apps turns that from a review question into a deployment fact: apps/web is given no Stripe key, no database URL and no GitHub token, so there is nothing in its environment to leak.

The reasoning, including what it costs, is in docs/adr/0002-express-api-owns-every-secret.md.

What is left in the web app's own server surface is small: resource routes serve /llms.txt, /llms-full.txt, /sitemap.xml and /robots.txt, and server functions load the bundled Docs and Posts. Those content paths are public and secret-free. Clerk request middleware is the narrow identity exception; business integrations remain in the api.

The shared package, and the line through it

@ffd/shared has exactly two entry points, and the split is a boundary rather than a filing system.

@ffd/shared is isomorphic. Response types, Zod request schemas and the pricing catalog live here, and everything reachable from it has to run in a browser: no secrets, no database, no process.env. The catalog is under this entry point specifically because the web app has to read it to render prices.

@ffd/shared/db is the Drizzle client and schema. apps/web is forbidden from importing it, and that is a lint error rather than a convention:

"noRestrictedImports": {
  "level": "error",
  "options": {
    "paths": {
      "@ffd/shared/db": "The web app never talks to Postgres. Call the api. ..."
    }
  }
}

That rule is in apps/web/biome.json, message abbreviated above, so pnpm check fails on the import rather than a reviewer catching it. The api has the mirror-image rule: noProcessEnv is an error everywhere in apps/api except src/env.ts, which is the only file in that app allowed to read the environment.

Do not add a third subpath. Two subpaths is one rule to learn, and it is the same rule as "secrets live in the api", stated in the import graph.

No build step

@ffd/shared is a Just-in-Time package. Its exports map points at raw .ts files:

"exports": {
  ".": "./src/index.ts",
  "./db": "./src/db/index.ts"
}

There is no dist and nothing to rebuild before the other packages can see a change. Vite transpiles the raw TypeScript import for the web app, and tsx transpiles it for the api. Editing a response type updates both apps' type checking on the next tsc run with nothing in between.

The cost is that anything you put in this package must be transpilable by both, so it cannot use Node-only APIs at the isomorphic entry point, and syntax Vite does not handle breaks the web build rather than the package's own.

What this costs you

Two deployments instead of one. Two sets of environment variables, two log streams, two things that can be down. The api is also the one that has to stay warm, because a cold Express process on the checkout path is a visible delay on the money page.

A CORS allowlist you have to maintain. Every origin that talks to the api is listed in CORS_ORIGINS, which means localhost, each preview domain, and production. A preview deploy on a domain nobody added answers every request with a CORS failure, and browser CORS errors do not say which side is wrong.

An extra network hop on every authenticated read. The dashboard renders, asks the api, and paints. That is why entitlement checks in the browser are loading states rather than instant, which is covered in Entitlements.

The two apps can disagree about which Stripe account they are pointed at. The api derives that from its secret key's prefix; the web app is told, through VITE_STRIPE_ENV, because it has no Stripe secret to derive it from. The mismatch is made visible rather than prevented: GET /health reports the api's answer, and POST /checkout/session returns a distinct stripe_env_mismatch code when a slug has no price in the api's account.

Where the decisions live

  • docs/adr/0002-express-api-owns-every-secret.md, why the api is a separate app
  • docs/adr/0006-api-is-the-sole-entitlement-authority.md, why no entitlement is mirrored into the client
  • docs/adr/0008-one-repo-storefront-is-the-boilerplate.md, why the storefront and the boilerplate are one repository
  • docs/adr/0010-typescript-7-and-biome.md, the toolchain that checks all of it

Who the caller is, and how the api finds out, is Identity.

Updated