Skip to content
tRPC

Server Actions

On this page

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.

Setting up Server Action procedures

1. Define a base procedure with experimental_caller

Use 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.

server/trpc.ts
import { const initTRPC: TRPCBuilder<object, object>initTRPC, class TRPCErrorTRPCError } from '@trpc/server';
import { 
function experimental_nextAppDirCaller<TContext, TMeta>(config: Simplify<{
    pathExtractor?: (opts: {
        meta: TMeta;
    }) => string;
    normalizeFormData?: boolean;
    onError?: (opts: ErrorHandlerOptions<TContext>) => void;
} & CreateContextCallback<TContext, () => MaybePromise<TContext>>>): CallerOverride<TContext>
experimental_nextAppDirCaller
} from '@trpc/server/adapters/next-app-dir';
interface Meta { Meta.span: stringspan: string; } export const
const t: TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, {
    ctx: object;
    meta: Meta;
    errorShape: DefaultErrorShape;
    transformer: false;
}>
t
= const initTRPC: TRPCBuilder<object, object>initTRPC.TRPCBuilder<object, object>.meta<Meta>(): TRPCBuilder<object, Meta>meta<Meta>().
TRPCBuilder<object, Meta>.create<RuntimeConfigOptions<object, Meta>>(opts?: RuntimeConfigOptions<object, Meta> | undefined): TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, {
    ctx: object;
    meta: Meta;
    errorShape: DefaultErrorShape;
    transformer: false;
}>
create
();
export const const serverActionProcedure: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>serverActionProcedure =
const t: TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, {
    ctx: object;
    meta: Meta;
    errorShape: DefaultErrorShape;
    transformer: false;
}>
t
.TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, { ctx: object; meta: Meta; errorShape: DefaultErrorShape; transformer: false; }>.procedure: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, false, DefaultErrorShape>procedure.ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, false, DefaultErrorShape>.experimental_caller(caller: CallerOverride<object>): ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>experimental_caller(
experimental_nextAppDirCaller<object, unknown>(config: {
    pathExtractor?: ((opts: {
        meta: unknown;
    }) => string) | undefined;
    normalizeFormData?: boolean | undefined;
    onError?: ((opts: ErrorHandlerOptions<object>) => void) | undefined;
    createContext?: (() => MaybePromise<object>) | undefined;
}): CallerOverride<object>
experimental_nextAppDirCaller
({
pathExtractor?: ((opts: {
    meta: unknown;
}) => string) | undefined
pathExtractor
: ({ meta: unknownmeta }) => (meta: unknownmeta as Meta)?.Meta.span: stringspan ?? '',
}), );

2. Add context via middleware

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:

server/trpc.ts
import { const initTRPC: TRPCBuilder<object, object>initTRPC, class TRPCErrorTRPCError } from '@trpc/server';
import { 
function experimental_nextAppDirCaller<TContext, TMeta>(config: Simplify<{
    pathExtractor?: (opts: {
        meta: TMeta;
    }) => string;
    normalizeFormData?: boolean;
    onError?: (opts: ErrorHandlerOptions<TContext>) => void;
} & CreateContextCallback<TContext, () => MaybePromise<TContext>>>): CallerOverride<TContext>
experimental_nextAppDirCaller
} from '@trpc/server/adapters/next-app-dir';
import {
function currentUser(): Promise<{
    id: string;
    name: string;
} | null>
currentUser
} from '../auth';
interface Meta { Meta.span: stringspan: string; } export const
const t: TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, {
    ctx: object;
    meta: Meta;
    errorShape: DefaultErrorShape;
    transformer: false;
}>
t
= const initTRPC: TRPCBuilder<object, object>initTRPC.TRPCBuilder<object, object>.meta<Meta>(): TRPCBuilder<object, Meta>meta<Meta>().
TRPCBuilder<object, Meta>.create<RuntimeConfigOptions<object, Meta>>(opts?: RuntimeConfigOptions<object, Meta> | undefined): TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, {
    ctx: object;
    meta: Meta;
    errorShape: DefaultErrorShape;
    transformer: false;
}>
create
();
export const
const serverActionProcedure: ProcedureBuilder<object, Meta, {
    user: {
        id: string;
        name: string;
    } | null;
}, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
serverActionProcedure
=
const t: TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, {
    ctx: object;
    meta: Meta;
    errorShape: DefaultErrorShape;
    transformer: false;
}>
t
.TRPCRootObject<object, Meta, RuntimeConfigOptions<object, Meta>, { ctx: object; meta: Meta; errorShape: DefaultErrorShape; transformer: false; }>.procedure: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, false, DefaultErrorShape>procedure
.ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, false, DefaultErrorShape>.experimental_caller(caller: CallerOverride<object>): ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>experimental_caller(
experimental_nextAppDirCaller<object, unknown>(config: {
    pathExtractor?: ((opts: {
        meta: unknown;
    }) => string) | undefined;
    normalizeFormData?: boolean | undefined;
    onError?: ((opts: ErrorHandlerOptions<object>) => void) | undefined;
    createContext?: (() => MaybePromise<object>) | undefined;
}): CallerOverride<object>
experimental_nextAppDirCaller
({
pathExtractor?: ((opts: {
    meta: unknown;
}) => string) | undefined
pathExtractor
: ({ meta: unknownmeta }) => (meta: unknownmeta as Meta)?.Meta.span: stringspan ?? '',
}), ) .
ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.use<{
    user: {
        id: string;
        name: string;
    } | null;
}>(fn: MiddlewareBuilder<{}, Meta, {
    user: {
        id: string;
        name: string;
    } | null;
}, UnsetMarker> | MiddlewareFunction<object, Meta, object, {
    user: {
        id: string;
        name: string;
    } | null;
}, UnsetMarker>): ProcedureBuilder<object, Meta, {
    user: {
        id: string;
        name: string;
    } | null;
}, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
use
(async (
opts: {
    ctx: {};
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<object>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
) => {
const
const user: {
    id: string;
    name: string;
} | null
user
= await
function currentUser(): Promise<{
    id: string;
    name: string;
} | null>
currentUser
();
return
opts: {
    ctx: {};
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<object>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
.
next: <{
    user: {
        id: string;
        name: string;
    } | null;
}>(opts: {
    ctx?: {
        user: {
            id: string;
            name: string;
        } | null;
    } | undefined;
    input?: unknown;
}) => Promise<MiddlewareResult<{
    user: {
        id: string;
        name: string;
    } | null;
}>> (+2 overloads)
next
({
ctx?: {
    user: {
        id: string;
        name: string;
    } | null;
} | undefined
ctx
: {
user: {
    id: string;
    name: string;
} | null
user
} });
});

3. Create a protected action procedure

Add an authorization middleware to create a reusable base for actions that require authentication:

server/trpc.ts
export const 
const protectedAction: ProcedureBuilder<object, Meta, {
    user: {
        id: string;
        name: string;
    };
}, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
protectedAction
=
const serverActionProcedure: ProcedureBuilder<object, Meta, {
    user: {
        id: string;
        name: string;
    } | null;
}, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
serverActionProcedure
.
ProcedureBuilder<object, Meta, { user: { id: string; name: string; } | null; }, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.use<{
    user: {
        id: string;
        name: string;
    };
}>(fn: MiddlewareBuilder<{
    user: {
        id: string;
        name: string;
    } | null;
}, Meta, {
    user: {
        id: string;
        name: string;
    };
}, UnsetMarker> | MiddlewareFunction<object, Meta, {
    user: {
        id: string;
        name: string;
    } | null;
}, {
    user: {
        id: string;
        name: string;
    };
}, UnsetMarker>): ProcedureBuilder<object, Meta, {
    user: {
        id: string;
        name: string;
    };
}, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
use
((
opts: {
    ctx: {
        user: {
            id: string;
            name: string;
        } | null;
    };
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<{
            user: {
                id: string;
                name: string;
            } | null;
        }>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
) => {
if (!
opts: {
    ctx: {
        user: {
            id: string;
            name: string;
        } | null;
    };
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<{
            user: {
                id: string;
                name: string;
            } | null;
        }>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
.
ctx: {
    user: {
        id: string;
        name: string;
    } | null;
}
ctx
.
user: {
    id: string;
    name: string;
} | null
user
) {
throw new
new TRPCError(opts: {
    message?: string;
    code: TRPC_ERROR_CODE_KEY;
    cause?: unknown;
}): TRPCError
TRPCError
({
code: "UNAUTHORIZED" | "PARSE_ERROR" | "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | "BAD_GATEWAY" | "SERVICE_UNAVAILABLE" | "GATEWAY_TIMEOUT" | "PAYMENT_REQUIRED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_SUPPORTED" | "TIMEOUT" | "CONFLICT" | "PRECONDITION_FAILED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "UNPROCESSABLE_CONTENT" | "PRECONDITION_REQUIRED" | "TOO_MANY_REQUESTS" | "CLIENT_CLOSED_REQUEST"code: 'UNAUTHORIZED', }); } return
opts: {
    ctx: {
        user: {
            id: string;
            name: string;
        } | null;
    };
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<{
            user: {
                id: string;
                name: string;
            } | null;
        }>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
.
next: <{
    user: {
        id: string;
        name: string;
    };
}>(opts: {
    ctx?: {
        user: {
            id: string;
            name: string;
        };
    } | undefined;
    input?: unknown;
}) => Promise<MiddlewareResult<{
    user: {
        id: string;
        name: string;
    };
}>> (+2 overloads)
next
({
ctx?: {
    user: {
        id: string;
        name: string;
    };
} | undefined
ctx
: {
...
opts: {
    ctx: {
        user: {
            id: string;
            name: string;
        } | null;
    };
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<{
            user: {
                id: string;
                name: string;
            } | null;
        }>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
.
ctx: {
    user: {
        id: string;
        name: string;
    } | null;
}
ctx
,
user: {
    id: string;
    name: string;
}
user
:
opts: {
    ctx: {
        user: {
            id: string;
            name: string;
        } | null;
    };
    type: ProcedureType;
    path: string;
    input: UnsetMarker;
    getRawInput: GetRawInputFn;
    meta: Meta | undefined;
    signal: AbortSignal | undefined;
    batchIndex: number;
    next: {
        (): Promise<MiddlewareResult<{
            user: {
                id: string;
                name: string;
            } | null;
        }>>;
        <$ContextOverride>(opts: {
            ctx?: $ContextOverride | undefined;
            input?: unknown;
        }): Promise<MiddlewareResult<$ContextOverride>>;
        (opts: {
            getRawInput: GetRawInputFn;
        }): Promise<...>;
    };
}
opts
.
ctx: {
    user: {
        id: string;
        name: string;
    } | null;
}
ctx
.
user: {
    id: string;
    name: string;
}
user
, // ensures type is non-nullable
}, }); });

Defining a server action

Create a file with the "use server" directive and define your action using the procedure builder:

app/_actions.ts
'use server';

import { import zz } from 'zod';
import { const protectedAction: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>protectedAction } from '../server/trpc';

export const 
const createPost: (input: {
    title: string;
}) => Promise<void>
createPost
= const protectedAction: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>protectedAction
.
ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.input<z.ZodObject<{
    title: z.ZodString;
}, z.core.$strip>>(schema: z.ZodObject<{
    title: z.ZodString;
}, z.core.$strip>): ProcedureBuilder<object, Meta, object, {
    title: string;
}, {
    title: string;
}, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
input
(
import zz.
function object<{
    title: z.ZodString;
}>(shape?: {
    title: z.ZodString;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    title: z.ZodString;
}, z.core.$strip>
object
({
title: z.ZodStringtitle: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), }), ) .
ProcedureBuilder<object, Meta, object, { title: string; }, { title: string; }, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.mutation<void>(resolver: ProcedureResolver<object, Meta, object, {
    title: string;
}, UnsetMarker, void>): (input: {
    title: string;
}) => Promise<void>
mutation
(async (
opts: ProcedureResolverOptions<object, Meta, object, {
    title: string;
}>
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.

Calling from client components

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:

app/post-form.tsx
'use client';

import { 
function createPost(input: {
    title: string;
}): Promise<void>
createPost
} from '../_actions';
export function function PostForm(): React.JSX.ElementPostForm() { return ( <React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form React.DOMAttributes<HTMLFormElement>.onSubmit?: React.SubmitEventHandler<HTMLFormElement> | undefinedonSubmit={async (e: React.SubmitEvent<HTMLFormElement>e) => { e: React.SubmitEvent<HTMLFormElement>e.React.BaseSyntheticEvent<SubmitEvent, EventTarget & HTMLFormElement, EventTarget>.preventDefault(): voidpreventDefault(); const const title: stringtitle = new var FormData: new (form?: HTMLFormElement, submitter?: HTMLElement | null) => FormDataFormData(e: React.SubmitEvent<HTMLFormElement>e.React.BaseSyntheticEvent<SubmitEvent, EventTarget & HTMLFormElement, EventTarget>.currentTarget: EventTarget & HTMLFormElementcurrentTarget).FormData.get(name: string): FormDataEntryValue | nullget('title') as string; await
function createPost(input: {
    title: string;
}): Promise<void>
createPost
({ title: stringtitle });
}} > <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.InputHTMLAttributes<HTMLInputElement>.type?: React.HTMLInputTypeAttribute | undefinedtype="text" React.InputHTMLAttributes<HTMLInputElement>.name?: string | undefinedname="title" /> <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.ButtonHTMLAttributes<HTMLButtonElement>.type?: "button" | "reset" | "submit" | undefinedtype="submit">Create Post</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button> </React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form> ); }

Adding observability with metadata

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:

app/_actions.ts
'use server';

import { import zz } from 'zod';
import { const protectedAction: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>protectedAction } from '../server/trpc';

export const 
const createPost: (input: {
    title: string;
}) => Promise<void>
createPost
= const protectedAction: ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>protectedAction
.ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.meta(meta: Meta): ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>meta({ Meta.span: stringspan: 'create-post' }) .
ProcedureBuilder<object, Meta, object, UnsetMarker, UnsetMarker, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.input<z.ZodObject<{
    title: z.ZodString;
}, z.core.$strip>>(schema: z.ZodObject<{
    title: z.ZodString;
}, z.core.$strip>): ProcedureBuilder<object, Meta, object, {
    title: string;
}, {
    title: string;
}, UnsetMarker, UnsetMarker, true, DefaultErrorShape>
input
(
import zz.
function object<{
    title: z.ZodString;
}>(shape?: {
    title: z.ZodString;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    title: z.ZodString;
}, z.core.$strip>
object
({
title: z.ZodStringtitle: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), }), ) .
ProcedureBuilder<object, Meta, object, { title: string; }, { title: string; }, UnsetMarker, UnsetMarker, true, DefaultErrorShape>.mutation<void>(resolver: ProcedureResolver<object, Meta, object, {
    title: string;
}, UnsetMarker, void>): (input: {
    title: string;
}) => Promise<void>
mutation
(async (
opts: ProcedureResolverOptions<object, Meta, object, {
    title: string;
}>
opts
) => {
// ... });

When to use Server Actions vs mutations

Server Actions are not a replacement for all tRPC mutations. Consider the tradeoffs:

  • Use Server Actions when you want progressive enhancement (forms that work without JavaScript), or when the action doesn't need to update client-side React Query cache.
  • Use 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.