/add-server-action
The canonical pattern for a server action. Server actions are the only place RPC clients are called from the app; components and forms never touch the client directly. Proto conversion happens here, at the edge — domain logic and proto shapes stay out of React.
Runs in flow-frontend. Two paired files per domain:
_lib/actions/<domain>.actions.ts ('use server') and _lib/actions/<domain>.client.ts
('use client'). Place them under the nearest feature's _lib/actions/, not at app root,
unless the action is genuinely app-wide.
Steps
Locate the RPC and its client. Find the generated client in
lib/proto/<service>(e.g.purchaseOrderClient) and the*Requiredrequest/response types in the@bufteam/hadrian_<service>.hadrian_protoc-gen-es-requiredpackage. If the client doesn't exist yet, that is a proto/setup gap — surface it, don't hand-roll a fetch.Write the
.actions.tsfile ('use server'at top). Wrap each RPC with the right helper from@hadrian-mtv/connect-server-actions/server:handleRevalidate(client.method, RequiredResponse, RequiredRequest)for mutations that should revalidate paths/tags.handleServerFetch(...)for reads invoked from the server.
'use server'; import { requireUpdateFooResponse, requireUpdateFooRequest, } from '@bufteam/hadrian_<service>.hadrian_protoc-gen-es-required/<path>/foo_required'; import { handleRevalidate } from '@hadrian-mtv/connect-server-actions/server'; import { fooClient } from '@/lib/proto/<service>'; export const updateFooAction = handleRevalidate( fooClient.updateFoo, requireUpdateFooResponse, requireUpdateFooRequest, );Convert at the edge, not in the component. Callers pass and receive plain form/domain shapes; map them to/from proto with
formToRpc*/rpcToForm*converters right here. A component must never assemble a proto request or read a raw proto response. Handle int64-as-string, RFC 3339 timestamps, and proto3 defaults in the converter — see theprotobufrule (installed at.claude/rules/protobuf.md).Write the
.client.tswrapper ('use client'). Re-export each action throughwithErrorsfrom@hadrian-mtv/connect-server-actions/clientso Connect errors serialize across the boundary into a result components can read:'use client'; import { withErrors } from '@hadrian-mtv/connect-server-actions/client'; import * as actions from './foo.actions'; export const updateFooAction = withErrors(actions.updateFooAction);Surface the why. In the component, drive the action with
useServerMutation/useServerMutationThenToast(from/client), or TanStackuseMutation. Pull the failure message withgetErrorMessage(app-local —apps/*/app/_lib/ErrorMessage/, not a package export) and show it — never swallow it or print a generic string. KeeponSuccess/onErrorseparate from the call itself; do not stuff side-effects into the mutation function.Verify.
pnpm lint:tscto confirm types line up, then add or update a test (/frontend-testing) that drives the real UI path and asserts the error message renders on failure and success state renders on success.
Boundaries
- Server actions live in
_lib/actions; never inline'use server'blocks in components. - No cross-cluster fetching from flow-global — keep each app's actions to its own services.
- Name files and the package by domain, not by app.
Gotchas
- The package scope is
hadrian_<service>— e.g.@bufteam/hadrian_factory-execution.hadrian_protoc-gen-es-required. Dropping thehadrian_prefix produces an unresolvable import that typechecks only afterpnpm installof a package that doesn't exist. Verify against thepnpm-workspace.yamlcatalog.