Skip to main content

Using Server Actions with tRPC

· 9 min read
Julius Marminge

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

note

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 install @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.ts
ts
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();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);
server/trpc.ts
ts
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();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).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.ts
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;
}
 
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) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});
server/trpc.ts
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;
}
 
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) => {
// Inject user into context
const user = await currentUser();
return opts.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.ts
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;
}
 
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) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
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
(property) user: User
},
});
});
server/trpc.ts
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;
}
 
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) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
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
(property) user: User
},
});
});

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.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = 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;
const createPost: (input: { title: string; }) => Promise<void>
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = 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;
const createPost: (input: { title: string; }) => Promise<void>

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.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}
app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await 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 context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export 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 context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export 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.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = 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.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = 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!