Domain features follow one shape: a server page.tsx reads, a client view renders, and an actions.ts next to them writes. There are 36 files with a top level "use server" directive in apps/saas.

The three files

page.tsx

This is a shortened version of coaches/page.tsx. The real page also loads the organization to find the owner and passes canManage. Rules that the existing pages follow:
  • params and searchParams are promises in Next.js 16. Await them.
  • Start independent reads together. Each performApi call is a network round trip to the core API, so sequential awaits add up. clients/page.tsx starts several promises early and awaits them where they are used.
  • Use tryPerformAll when the page cannot render without the data, and safePerformApi or a .catch() fallback when it can.
  • Pass the slug down as a prop. Views need it to call actions.

actions.ts

Every action file begins with the same guard:
An action takes the slug as its first argument, resolves the context again, calls the API and revalidates.
The context is resolved from the session on every call. A slug the user does not belong to produces null and the action throws. The studio id is never taken from the request body.
Server actions are public HTTP endpoints. Do not rely on the page having checked access. Role checks that matter are enforced by the core API from the x-user-role header. A few actions add their own gate, for example loadAutofitSync returns { ok: false } unless canManageAutofit(ctx).

Throw or return a result

There are two styles and the choice is deliberate.

Throwing actions

Most actions let performApi throw. The client wraps the call in toastAction from @repo/ui:
That call is from exercises/ExercisesView.tsx. toastAction rethrows after showing the toast, so the caller wraps it in try and catch to stop on failure. toastAction shows the localized error string as the headline. It also tries to show the server’s own explanation underneath through serverExplanation() in packages/ui/lib/server-error.ts, but only for 4xx failures and only when the error still has its message.

Result returning actions

In production Next.js replaces the message of anything thrown from a server action with a digest. serverExplanation sees the digest and returns nothing. So when the refusal has to be read by the coach, the action returns an ApiResult and the view renders it.
Actions written this way include freezePlan, reactivatePlan, addSubscription, startSubscription and assignForm in clients/[clientId]/actions.ts, and the three assistant actions. The view then reads result.error:
  • error.details carries structured context from the API. isFormInactiveFailure(failure) in clients/board/form-send-failure.ts checks details.reason === "FORM_INACTIVE". planLimitFromDetails(details) in modules/payments/lib/plan-limit.ts detects PLAN_LIMIT_TRAINEES and PLAN_LIMIT_SEATS and opens the plan limit dialog.
  • serverExplanation(failure) works on an ApiFailure too, because it carries status and the original message. It strips the core-api request failed: 409 prefix and returns the API’s sentence when it is 400 characters or shorter.
Rule of thumb: if the API can refuse for a reason the coach can act on (a date clash, an inactive form, a plan limit), return ApiResult. If failure only ever means “try again”, throw and use toastAction.

Refreshing data after a write

Two mechanisms are used together. Many views also update local state from the action’s return value, so the list changes immediately without waiting for a refresh. The trainee board keeps its own list state and merges refreshed rows with withRefreshedClients() in clients/board/trainee-board-logic.ts. modules/shared/lib/cache.ts exports a clearCache(path?) action that revalidates one path or the whole layout.

Actions used as loaders

Some actions only read. Client components call them to load data on demand: loadClientsPage for the trainee list’s next page, getClientBoard for the detail pane, loadFoodLibrary and loadExerciseLibrary for the builders. Next.js runs the server actions of a page through a single queue, one after another. A slow read therefore delays a write queued behind it, and the browser cannot cancel it. The work board hit this, and its panel reads were moved to GET route handlers. See Route handlers. When a client component needs several loader actions to behave like queries, wrap them in TanStack Query with a slug scoped key, as the builders do with ["food-library", slug]. See Data fetching.

Running actions in order

modules/shared/hooks/use-async-action.ts exports useAsyncAction(), which returns { pending, run }. run(action) puts the call on a per component promise queue, so two quick clicks execute in order and pending stays true until the queue is empty. createAsyncActionQueue() is the same queue without React.

Service wrappers

modules/shared/services holds four server only objects that group related calls and their safe variants: They are optional. Newer areas call performApi directly from the page and the action file.

Body size

next.config.ts sets experimental.serverActions.bodySizeLimit to 24mb. Small files are sent to an action as a base64 data URL. Anything over 5 MiB, and every video, uses the chunked relay instead. See Uploads.

Server action hardening

Two pieces protect the action endpoint itself.
  • proxy.ts rejects any request whose next-action header is not 40 or 42 lowercase hex characters with 400 invalid server action, before Next.js sees it. proxy.test.ts covers this.
  • The dev.yml workflow derives NEXT_SERVER_ACTIONS_ENCRYPTION_KEY from BETTER_AUTH_SECRET and sets DEPLOYMENT_VERSION to the commit SHA (deploymentId in next.config.ts). A stable key keeps action references valid across instances of the same release.

Checklist for a new action

1

Put it next to the feature

Add it to the actions.ts of the route folder that owns the write.
2

Take the slug first

Resolve the context with ctxOrThrow(slug). Never accept a studio id from the client.
3

Type the response

Use a type from perform-types.ts, or add one there.
4

Decide on the failure style

Return ApiResult when the coach needs to read why it failed. Otherwise throw.
5

Revalidate

Call revalidatePath for every route that shows the changed data.
6

Keep it out of client bundles

Import the action into the view, never performApi. server-only will fail the build if you get this wrong.