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_URLplus the incoming pathname and query string./api/auth/get-sessionon the web origin becomes/api/auth/get-sessionon 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
ArrayBufferfor methods other thanGETandHEAD. - Uses
redirect: "manual", so OAuth redirects from better-auth reach the browser unchanged. - Copies response headers back and appends every
set-cookieseparately withgetSetCookie(). Session cookies are therefore set on the web origin. - Returns
503withcode: "SERVICE_UNAVAILABLE"when the API cannot be reached (ECONNREFUSED,ENOTFOUND,ECONNRESETor afetch failedmessage).
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.
Channel 2: cookie forwarding from the server
Server components cannot go through the browser, somodules/shared/lib/bff-server-fetch.ts calls the core API directly and forwards the incoming cookie header.
fetchAuthApi
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:
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:
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 anApiResult 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
appsorpackagesother thanapps/saas/modules/shared/lib/core-api.tsmentionsCORE_API_URLorSERVICE_AUTH_SECRET. - Any file mentions
NEXT_PUBLIC_CORE_API. - A file with
"use client"referencescoreApiFetchorlib/core-api. core-api.tsis missing or does not importserver-only.
"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.