After the server consolidation the core API hosts everything: better-auth, the oRPC router and the domain REST API. The web app reaches it in three ways. Which one a piece of code uses depends on where it runs and what it needs. There is no rewrites() entry for the API in next.config.ts and proxy.ts does not forward API traffic. The proxying is done by a route handler.

Channel 1: the /api catch-all proxy

app/api/[[...rest]]/route.ts exports the same handler for GET, POST, PUT, PATCH, DELETE and OPTIONS:
proxyToCoreApi in modules/shared/lib/bff-proxy.ts:
  • Builds the target as CORE_API_URL plus the incoming pathname and query string. /api/auth/get-session on the web origin becomes /api/auth/get-session on the core API.
  • Forwards all request headers except hop by hop ones (connection, keep-alive, transfer-encoding, te, trailer, upgrade, proxy-authorization, proxy-authenticate, content-encoding, content-length, host). Cookies pass through.
  • Reads the body as an ArrayBuffer for methods other than GET and HEAD.
  • Uses redirect: "manual", so OAuth redirects from better-auth reach the browser unchanged.
  • Copies response headers back and appends every set-cookie separately with getSetCookie(). Session cookies are therefore set on the web origin.
  • Returns 503 with code: "SERVICE_UNAVAILABLE" when the API cannot be reached (ECONNREFUSED, ENOTFOUND, ECONNRESET or a fetch failed message).
Who uses it: More specific route handlers under app/api (uploads, signing, work board reads) win over the optional catch-all. See Route handlers. next.config.ts raises experimental.proxyClientMaxBodySize and serverActions.bodySizeLimit to 24mb so base64 uploads fit. Server components cannot go through the browser, so modules/shared/lib/bff-server-fetch.ts calls the core API directly and forwards the incoming cookie header.

fetchAuthApi

It issues a GET to CORE_API_URL + "/api/auth" + path with cache: "no-store". A 401, 403, 404 or any 5xx returns null instead of throwing. Other failures throw. modules/auth/lib/server.ts wraps it in React cache() so each helper runs once per request: Paths are constants in modules/shared/lib/auth-api-routes.ts. Every helper catches errors and returns null or an empty list.

createServerOrpcClient

Returns an oRPC client whose link points at CORE_API_URL + "/api/rpc" and forwards the cookie header. modules/payments/lib/server.ts uses it for listPurchases(organizationId).

The super admin helper

modules/admin/lib/admin-api.ts exports adminApi(path, query) and adminApiPost. They call CORE_API_URL + "/api" + path with the forwarded cookie. The admin screens use this for plain reads because, as the file comment says, the generated oRPC type is kept by hand and lags new procedures.

Channel 3: the signed web lane

Domain data (trainees, plans, forms, tasks and so on) goes through the service to service lane at /v1/web/*. The web server signs each request, and the core API trusts the identity headers because the signature proves the caller.

core-api.ts: signing and transport

modules/shared/lib/core-api.ts imports server-only. It reads CORE_API_URL, CORE_API_SERVICE_ID (default web) and SERVICE_AUTH_SECRET. The signature is an HMAC SHA-256, hex encoded, over five lines joined with \n:
The path includes the query string. The body hash covers the bytes actually sent: the JSON string, an empty string for no body, or the raw buffer for a binary part. Headers sent on every request: Exports: rawBody and body are mutually exclusive. rawBody exists for the chunked upload relay, where a multipart part must reach the API as untouched bytes.

perform-api.ts: studio context

Page and action code never calls coreApiFetch directly. It uses two functions from modules/shared/lib/perform-api.ts.
resolveStudioContext(slug) loads the session and the organization in parallel, finds the caller’s membership and returns:
It returns null when there is no session or the organization cannot be loaded. The organization role maps to the studio role like this: performApi<T>(ctx, { method, path, body, rawBody, query }) prefixes the path with /v1/web, builds the query string (skipping undefined and empty strings, repeating the key for arrays), adds the identity headers and unwraps the { ok, data } envelope. Names are URI encoded because header values must be ASCII and studio names are usually Hebrew. performApiResponse(ctx, { path, query }) makes the same call but returns the raw Response. The PDF relays use it to stream bytes.

Other signed callers

The onboarding and waitlist route handlers call /v1/public/* with a plain unsigned fetch.

Error handling

ApiResult

modules/shared/lib/safe-perform-api.ts turns thrown errors into values.
A non CoreApiError failure (the API is down, DNS, a timeout) becomes { status: 503, code: "SERVICE_UNAVAILABLE" }.

In pages

ApiLoadError (modules/shared/components/ApiLoadError.tsx) is a server component that renders the localized errors.title and errors.description alert. Anything a page does not catch lands in [organizationSlug]/error.tsx, which offers a retry button.

In actions

Next.js replaces the message of an error thrown from a server action with an opaque digest in production. So an action whose refusal the coach has to read returns an ApiResult instead of throwing. See Server actions.

The boundary check

tools/check-no-direct-core-api.sh (pnpm check:core-api, also the first step in ci.yml) is meant to keep the API URL and secret in one file. It fails when:
  • Any file under apps or packages other than apps/saas/modules/shared/lib/core-api.ts mentions CORE_API_URL or SERVICE_AUTH_SECRET.
  • Any file mentions NEXT_PUBLIC_CORE_API.
  • A file with "use client" references coreApiFetch or lib/core-api.
  • core-api.ts is missing or does not import server-only.
The first rule no longer matches the code. bff-proxy.ts, bff-server-fetch.ts, modules/admin/lib/admin-api.ts and its test, the four api/onboarding/* routes and api/waitlist/route.ts all read process.env.CORE_API_URL. Reading the script against the current tree, it reports nine files. Either the allow list needs those files or they need to go through a shared helper. This was not run as part of writing these docs.
The rule that still holds in practice: nothing with "use client" imports core-api.ts, perform-api.ts or bff-server-fetch.ts. All of them import server-only, so a mistake fails the build.

Domain types

modules/shared/lib/perform-types.ts (about 1,470 lines) holds the response shapes: Paginated<T>, Client, Coach, Program, ProgramTemplate, ProgramFolder, Product, FormTemplate, FormResponse, InboxItem, CalendarEvent and many more. They are written by hand to match the API. When the backend adds a field, add it here. core-api-routes.ts exports CORE_API_ROUTES, a map of path constants and builders. The service files under modules/shared/services use it. Most actions.ts files write the path inline.