Using Server Actions with tRPC
The builder-pattern for creating procedures which was introduced in tRPC v10 has been massively appreciated by the community, and many libraries have adopted similar patterns.
There's even been coined a term tRPC like XYZ
as evidence of the growing popularity of this pattern. In fact, the other day I saw someone
wondering if there was a way to write CLI applications with a similar API to tRPC.
Sidenote, you can even use tRPC directly to do this. But that's not what we're here to talk about today,
we're going to talk about how to use tRPC with server actions from Next.js.
What's a server action?
In case you live under a rock and haven't kept up with the latest React and Next.js features, server actions allows you to write regular functions that are executed on the server, import them on the client and call them just as if they were regular functions. You may think that this sounds similar to tRPC which is true. According to Dan Abramov, server actions are tRPC as a bundler feature:
And this is totally accurate, server actions are similar to tRPC, at the end of the day they're both RPCs. Both allow you to write functions on the backend and call them with full typesafety on the frontend with the network layer abstracted away.
So where does tRPC come in? Why would I need both tRPC and server actions? Server actions is a primitive, and as for all primitives they're quite barebones and thus lacking some fundamental aspects when it comes to building APIs. For any API endpoint that is exposed over the network, you need to validate and authorize requests to ensure your API is not maliciously used. As previously mentioned, tRPC's API is appreciated by the community, so wouldn't it be nice if we could use tRPC to define server actions and utilize all the awesome features that come built-in with tRPC such as input validation, authentication and authorization through middlewares, output validation, data transformers, etc, etc? I think so, so let's dig in.
Defining server actions with tRPC
Prerequisites: In order to use server actions, you need to use the Next.js App Router. Additionally, all the tRPC stuff we'll use are only available on tRPC v11, so make sure you're using the beta release channel of tRPC:
- npm
- yarn
- pnpm
- bun
npm install @trpc/server@next
yarn add @trpc/server@next
pnpm add @trpc/server@next
bun add @trpc/server@next
Let's start off by initializing tRPC and defining our base server actions procedure.
We'll use the experimental_caller
method on the procedure builder, which is a new method that allows you to
customize the way that the procedure is called when it's invoked as a function. We'll also use the adapter experimental_nextAppDirCaller
to make it compatible with Next.js. This adapter will handle cases where the server action is wrapped in useActionState
on the client,
which changes the call signature of the server action.
We'll also be using a span
property as metadata, since there is no ordinary path like when you use a router (user.byId
for example). You can use the span property to differentiate procedures, for example during logging or observability.
server/trpc.tsts
import {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
server/trpc.tsts
import {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
Next, we'll add some context. Since we wont be hosting a router using a regular HTTP adapter, we won't have any context injected through the createContext
method on the adapter. Instead, we'll use a middleware to inject our context. In this example, let's retrieve the current user from the session, and inject it into the context.
server/trpc.tsts
import {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
server/trpc.tsts
import {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
Lastly, we'll create a protectedAction
procedure that will protect any action from unauthenticated users. If you have an existing middleware that does this you can use that, but I'll define one in-line for this example.
server/trpc.tsts
import {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
server/trpc.tsts
import {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
Alright, let's write an actual server action. Create an _actions.ts
file, decorate it with the "use server"
directive, and define your action.
app/_actions.tsts
'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async ({ctx ,input }) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
app/_actions.tsts
'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async ({ctx ,input }) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
Wow, it's that easy to define a server action that's protected from unauthenticated users, with input validation to protect against attacks such as SQL injections. Let's import this function on the client and call it.
app/post-form.tsxtsx
'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
app/post-form.tsxtsx
'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
Going further
Using tRPC builders and it's composable way of defining reusable procedures, we can easily build more complex server actions. Below are some examples:
Observability
You can use @baselime/node-opentelemtry
's trpc plugin to add observability in just a few lines of code:
diff
--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async ({ ctx, input }) => {// Do something with the input});
diff
--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async ({ ctx, input }) => {// Do something with the input});
Checkout the Baselime tRPC Integration for more information. Similar patterns should work for whatever observability playform you're using.
Rate Limiting
You can use a service such as Unkey to rate limit your server actions. Here's an example of a protected server action that uses Unkey to rate limit the number of requests per user:
server/trpc.tsts
import {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
server/trpc.tsts
import {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
app/_actions.tsts
'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async ({ctx ,input }) => {console .log (`${ctx .user .name } commented on ${input .postId } saying ${input .content }`,);});
app/_actions.tsts
'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async ({ctx ,input }) => {console .log (`${ctx .user .name } commented on ${input .postId } saying ${input .content }`,);});
Read more on rate limiting your tRPC procedures in this post by the folks over at Unkey.
The possibilities are endless, and I bet you already got a ton of nice utility middlewares you're using in your tRPC applications today. If not, you might found some out there you can npm install
!
Wrapping up
Server Actions are by no means a silver bullet. In places that requires more dynamic data, you might want to keep your data in the client-side React Query cache, and do mutations using useMutation
instead. That's totally valid. These new primitives should also be easy to incrementally adopt, so you can move individual procedures over from your existing tRPC API to server actions in places where it makes sense to do so. There's no need to rewrite your entire API.
By defining your server actions using tRPC, you can share a lot of the same logic you're using today and choose where you expose the mutation as a server action or as a more traditional mutation. You as a developer have the power to pick what patterns works best for your application. In case you're not using tRPC today, there are some packages (next-safe-action and zsa comes to mind) that let's you define type-safe, input validated server actions worth checking out as well.
If you wanna see an app using this in action, check out Trellix tRPC, an app I made recently utilizing these new primitives.
What do you think? We want your feedback
So, what do you think? Let us know over at Github and help us iterate to get these primitives to a stable state.
There's still some work to be done, especially regarding error handling. Next.js advocates for returning errors, and we'd like to make this as typesafe as possible. Check out this WIP PR by Alex for some early work on this.
Until next time, happy coding!