www/docs/client/nextjs/app-router/server-actions.mdx
Server Actions allow you to define functions on the server and call them directly from client components, with the network layer abstracted away by the framework.
By defining your server actions using tRPC procedures, you get all of tRPC's built-in features: input validation, authentication and authorization through middlewares, output validation, data transformers, and more.
:::info
The Server Actions integration uses the experimental_ prefix and is still under active development. The API may change in future releases.
:::
experimental_callerUse experimental_caller on a procedure builder together with experimental_nextAppDirCaller to create procedures that can be invoked as plain functions (server actions). The pathExtractor option lets you identify procedures by metadata, which is useful for logging and observability since server actions don't have a router path like user.byId.
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
interface Meta {
span: string;
}
export const t = initTRPC.meta<Meta>().create();
export const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta)?.span ?? '',
}),
);
Since server actions don't go through an HTTP adapter, there's no createContext to inject context. Instead, use a middleware to provide context such as session data:
// @filename: auth.ts
export declare function currentUser(): Promise<{ id: string; name: string } | null>;
// @filename: server/trpc.ts
// ---cut---
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from '../auth';
interface Meta {
span: string;
}
export const t = initTRPC.meta<Meta>().create();
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta)?.span ?? '',
}),
)
.use(async (opts) => {
const user = await currentUser();
return opts.next({ ctx: { user } });
});
Add an authorization middleware to create a reusable base for actions that require authentication:
// @filename: auth.ts
export declare function currentUser(): Promise<{ id: string; name: string } | null>;
// @filename: server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from '../auth';
interface Meta { span: string; }
const t = initTRPC.meta<Meta>().create();
const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({ pathExtractor: ({ meta }) => (meta as Meta)?.span ?? '' }),
)
.use(async (opts) => {
const user = await currentUser();
return opts.next({ ctx: { user } });
});
// ---cut---
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // ensures type is non-nullable
},
});
});
Create a file with the "use server" directive and define your action using the procedure builder:
// @filename: server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
interface Meta { span: string; }
const t = initTRPC.meta<Meta>().create();
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({ pathExtractor: ({ meta }) => (meta as Meta).span }),
);
export const protectedAction = serverActionProcedure;
// @filename: app/_actions.ts
// ---cut---
'use server';
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
export const createPost = protectedAction
.input(
z.object({
title: z.string(),
}),
)
.mutation(async (opts) => {
// opts.ctx.user is typed as non-nullable
// opts.input is typed as { title: string }
// Create the post...
});
Because of experimental_caller, the procedure is now a plain async function that can be used as a server action.
Import the server action and use it in a client component. Server actions work with both the action attribute for progressive enhancement and programmatic calls via onSubmit:
// @jsx: react-jsx
// @filename: _actions.ts
export declare function createPost(input: { title: string }): Promise<void>;
// @filename: app/post-form.tsx
// ---cut---
'use client';
import { createPost } from '../_actions';
export function PostForm() {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.currentTarget).get('title') as string;
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}
Use the .meta() method to tag actions for logging or tracing. The span property from the metadata is passed to pathExtractor, so it can be used by observability tools:
// @filename: server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
interface Meta { span: string; }
const t = initTRPC.meta<Meta>().create();
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({ pathExtractor: ({ meta }) => (meta as Meta)?.span ?? '' }),
);
export const protectedAction = serverActionProcedure;
// @filename: app/_actions.ts
// ---cut---
'use server';
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
export const createPost = protectedAction
.meta({ span: 'create-post' })
.input(
z.object({
title: z.string(),
}),
)
.mutation(async (opts) => {
// ...
});
Server Actions are not a replacement for all tRPC mutations. Consider the tradeoffs:
useMutation when you need to update the client-side cache, show optimistic updates, or manage complex loading/error states in the UI.You can incrementally adopt server actions alongside your existing tRPC API - there's no need to rewrite your entire API.