In Reelax Brands V2 the browser never talks to our API directly. Every call goes through a typed Server Action with one wrapper doing validation, auth and error shaping.
Brands V2 is the dashboard brands use to find creators, build collections and run outreach. The API behind it returns more than any one screen needs: internal IDs and fields the UI never shows. If the browser fetches the API directly, all of that is one DevTools tab away, along with the API itself.
So the browser never calls the API. Each feature has an actions.ts file marked "use server", and every function in it is built with the same createAction wrapper.
export const searchCreatorsAction = createAction({
input: searchCreatorsInputSchema, // zod
handler: async (input) => findCreators(input),
});What the wrapper does
- Validates the input with a zod schema. Invalid input never reaches a handler; the client gets INVALID_INPUT and a human message.
- Resolves the session unless the action opts out with auth: "none", and hands it to the handler.
- Returns a discriminated result, { ok: true, data } or { ok: false, error: { code, message } }, so the client handles both cases explicitly.
- Re-throws Next's redirect() and notFound() so they still work as control flow.
- Logs known errors with a request ID and returns a user-safe message, never the raw upstream error.
The handler returns only what the screen renders. The query layer underneath can be as chatty as it likes, because its output never leaves the server.
One deployment gotcha
When we moved hosting to Cloudflare, /login started returning a 500. It came down to the server action key and session handling on the new runtime. Server Actions are addressed by IDs the framework generates, and anything that changes between builds or instances will break them in production even when development works perfectly.
The pattern costs one wrapper and one schema per action. In return, the attack surface is a list of functions you can read in one file per feature.