Type-safe RPC with Next.js Server Actions: skip the API layer
A Server Action is already an RPC. Here is how to make it type-safe end to end and validated at runtime, with no API route or response type to maintain.
You can call a server function from a React component the way you call a local one: pass arguments, read a return value, and let the compiler check both ends. That is what a Server Action gives you in Next.js. Type-safe RPC with Next.js Server Actions means the function on the server and the call on the client share one type, so there is no hand-written API route, no fetch wrapper, and no response shape to keep in sync. The network request is still there. You stop writing it by hand.
By the end of this guide you will have a server function that the editor types end to end, that rejects bad input at runtime, that checks who is calling before it runs, and that returns typed errors instead of throwing. That last step is where most teams stop early, and it is where the security holes sit.
What type-safe does and does not mean here
A Server Action is an RPC. The tRPC team describes it the same way: you write a function on the backend and call it from the frontend with the network layer abstracted away. The catch is that a TypeScript type is a compile-time contract only. Types are erased before the code runs, so they constrain your code, never the person sending the request.
Every function you mark 'use server' compiles to a public HTTP endpoint. Anyone can POST to it with any payload: null, an object where you expected a string, a 10 MB blob. The action itself is a public endpoint, and your component boundaries, client-side checks, and function signatures mean nothing to a request sent with curl. So type-safe in the editor and validated on the wire are two separate properties. The pattern below earns both from one source of truth.
Build it in five steps
Step 1: Treat the action as a public endpoint
Before you write the function body, accept what the function is: an unauthenticated POST route. Three checks belong inside every action that touches data: input validation, an authentication check (unless the action is genuinely public), and an authorization check on ownership or permission. Put them in the action, not in the component that calls it. The component runs on a device you do not control.
Step 2: Write the contract as a schema, not a type
Define the input with a validation schema. Zod is the common choice; Valibot and ArkType behave the same way through Standard Schema. One schema gives you two things at once: a static type the compiler infers, and a runtime check the server runs on every call. The type is inferred from the schema while the incoming input is validated against it, so the two can never drift apart.
const profileSchema = z.object({ name: z.string().min(1).max(80), bio: z.string().max(280) })Write the schema once. The component, the action, and the database call all read the same shape.
Step 3: Wrap every action in one client
Hand-wiring validation and auth into each action drifts within a month. A library like next-safe-action gives you a single action client with composable .use() middleware and typed context that flows into the handler. You attach auth and rate limiting once, and every action built on the client inherits them. The schema guarantees that parsedInput is fully typed and validated before your code runs.
export const updateProfile = actionClient.schema(profileSchema).action(async ({ parsedInput, ctx }) => { return db.profile.update(ctx.user.id, parsedInput) })Step 4: Call it from the component and handle validation errors
On the client you call the action through a hook. React 19's useActionState works too. When input fails the schema, a validationErrors object comes back to the client with every field that failed and its message, so the form can show errors inline without a second round trip. The flattenValidationErrors() helper reshapes that object to match your form library.
const { execute, result } = useAction(updateProfile)Step 5: Return typed errors, do not throw
A thrown error crosses the network boundary as a generic server error and loses its type on the client. Return a typed result object instead, with a success branch and a failure branch. Now the component branches on the outcome with the compiler checking both paths, and you never ship a raw exception message to the browser.
How to verify it actually works
Three tests confirm the contract holds:
- Editor test. Change the schema or the return shape. The call site should turn red before you save. If it does not, the type is not flowing end to end.
- curl test. POST a malformed payload straight to the action endpoint, bypassing the form. A correct action rejects it with a validation error. A broken one crashes or writes garbage to the database.
- Auth test. Call the action with no session. It should refuse before running any logic, because the gate lives inside the action.
Common failures and how to fix them
Trusting the type as the guard. The single most common mistake. A TypeScript type is documentation the compiler enforces on your code, and nothing more. Add the runtime schema even when the type looks airtight.
No auth inside the action. Authentication in the page does not protect the endpoint. Move the session check into the action or its middleware.
Leaking fields on the way out. Returning a full database row sends columns the client should never see. Return an explicit shape, or validate the output too.
Throwing for control flow. Reserve thrown errors for genuine faults. Use a typed return value for expected outcomes like a failed validation or a permission denial.
Using actions for heavy reads. Server Actions run sequentially and are not batched. A dashboard firing ten parallel reads through actions will feel slow.
When a Server Action is the wrong tool
Server Actions fit mutations and pages that stay mostly static after the first load. For a data-heavy UI with pagination, filtering, and sorting that refetches after load, or a backend that also serves a mobile app, tRPC with TanStack Query is the better fit: it batches concurrent calls into one request and serves more than one client with full type safety. The honest rule is to read for free from server components, mutate with type-safe actions, and reach for tRPC when the read side grows its own demands.
Going further
The validation step here is the same gate that closes most Server Action vulnerabilities. We went through the full attack surface in Next.js Server Actions security. If you are still deciding which logic belongs on the server at all, the Server Components vs Client Components decision tree is the companion piece.
Sources
Frequently asked questions
- Do I still need Zod if my Server Action is fully typed in TypeScript?
- Yes. A TypeScript type only exists while the code compiles. It is erased before the function runs, so it constrains your own code and nothing reaching the endpoint from outside. A Server Action is a public HTTP route, and an attacker can POST any payload directly with curl. The Zod schema is the runtime check that the type cannot perform. Keep both: the schema validates at runtime, and the compiler infers the type from that same schema.
- Is a Server Action slower than a tRPC call?
- For a single mutation, the difference is negligible. The gap shows up under load: Server Actions run sequentially and are not batched, so a screen that fires several actions at once pays a round trip for each one. tRPC batches concurrent calls into a single HTTP request. If your UI triggers many parallel reads after the initial load, that batching matters. For one save or one update on a form, a Server Action is the lighter tool.
- Can I reuse one Server Action for both a web app and a mobile app?
- Not cleanly. A Server Action is bound to the Next.js route that defines it and is invoked through the framework's own protocol, not a stable public API you can call from a native client. If a mobile app needs the same logic, expose it through a route handler or a tRPC procedure that both clients can call, and keep the Server Action as a thin wrapper over the shared function. The business logic lives in a plain function; the action and the API are two doors into it.
- How do I show form validation errors without throwing from the action?
- Return them instead of throwing. With next-safe-action, a failed schema check returns a validationErrors object listing each field and its message, and flattenValidationErrors() reshapes it for your form library. On the client, React 19's useActionState or the library hook exposes that result so the form renders the errors inline. A thrown error would cross the boundary as a generic server error and lose the per-field detail, which is exactly what a form needs to show.
Studio
Start a project.
One partner for the digital product you need to build. Faster delivery, modern tech, lower costs. One team, one invoice.