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
coaches/page.tsx. The real page also loads the organization to find the owner and passes canManage.
Rules that the existing pages follow:
paramsandsearchParamsare promises in Next.js 16. Await them.- Start independent reads together. Each
performApicall is a network round trip to the core API, so sequential awaits add up.clients/page.tsxstarts several promises early and awaits them where they are used. - Use
tryPerformAllwhen the page cannot render without the data, andsafePerformApior 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:
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 letperformApi throw. The client wraps the call in toastAction from @repo/ui:
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.
freezePlan, reactivatePlan, addSubscription, startSubscription and assignForm in clients/[clientId]/actions.ts, and the three assistant actions.
The view then reads result.error:
error.detailscarries structured context from the API.isFormInactiveFailure(failure)inclients/board/form-send-failure.tschecksdetails.reason === "FORM_INACTIVE".planLimitFromDetails(details)inmodules/payments/lib/plan-limit.tsdetectsPLAN_LIMIT_TRAINEESandPLAN_LIMIT_SEATSand opens the plan limit dialog.serverExplanation(failure)works on anApiFailuretoo, because it carriesstatusand the original message. It strips thecore-api request failed: 409prefix and returns the API’s sentence when it is 400 characters or shorter.
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.tsrejects any request whosenext-actionheader is not 40 or 42 lowercase hex characters with400 invalid server action, before Next.js sees it.proxy.test.tscovers this.- The
dev.ymlworkflow derivesNEXT_SERVER_ACTIONS_ENCRYPTION_KEYfromBETTER_AUTH_SECRETand setsDEPLOYMENT_VERSIONto the commit SHA (deploymentIdinnext.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.