Skip to content

react-monorepo architecture

react-monorepo: four apps over shared packages, one API · Open full screen ↗

Each app’s src/app/providers.tsx configures the shared packages at module scope, before the first request:

  • configureApi({ baseUrl: VITE_APP_API_URL, onUnauthorized: redirectToLogin });
  • configureAuth({ authApiBaseUrl: <API>/auth, authAppUrl, trustedOrigins, defaultRedirect }), where authAppUrl is the auth app’s origin (VITE_APP_AUTH_HOST) and trustedOrigins lists every sibling app.

Then the same provider tree everywhere: ThemeProvider → MetaProvider → Redux Provider → AuthProvider.

Signing in on the auth app, landing back on the client · Open full screen ↗
  1. The client app asks the API for the session (/auth/get-session) with the cookie. While that round trip is in flight, ProtectedRoute shows a skeleton.
  2. With no session, redirectToLogin() sends the browser to the auth app: <auth>/login?redirect=<where you were>.
  3. The auth app signs in against the API. better-auth sets the session cookie on the parent domain (BETTER_AUTH_COOKIE_DOMAIN, e.g. .example.com), so every subdomain sends it.
  4. The auth app sends you back to redirect, but only if its origin is one of the trusted sibling apps; otherwise to the client app.
  5. Back on the client, the session check now succeeds.

Any 401 from the API, in any app, triggers the same redirect. Sign-out clears the cookie and tells other tabs of the same app through a BroadcastChannel (other apps notice on their next request).

One RTK Query baseApi in @scope/api, which every feature extends with injectEndpoints in features/<name>/api.ts of its app. All requests include credentials; every 401 goes through one chokepoint. Tag types and endpoint names share one global namespace across every app and package: collisions are silent, so declare tags in base-api.ts and keep them specific.

@scope/types holds TypeScript types and Zod schemas generated from the API’s OpenAPI document. Every app compiles against them, so regenerate and commit them when the API changes. See Development.

apps/admin renders only for the admin and superuser roles (RoleRoute); signed-out visitors go to the auth app, and other users see Forbidden. That’s the UI; the API must enforce the same roles on every endpoint the console calls.

apps/landing builds robots.txt, sitemap.xml and security.txt at build time, and marks every build that isn’t for the production origin as noindex, so a preview can’t outrank production. Set PRODUCTION_ORIGIN in src/features/seo/routes.ts to your real origin. Pages are rendered in the browser: crawlers that don’t run JavaScript see an empty shell unless you add a prerender step.

As in react-app: light, dark or system per origin, applied before first paint, and OpenObserve RUM in every app (each with its own service name) with traceparent on API calls.