Saltar al contenido principal

Uso de Server Actions con tRPC

· 9 min de lectura
Julius Marminge
tRPC Core Team Member
Traducción Beta No Oficial

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

El patrón de construcción (builder-pattern) para crear procedimientos, introducido en tRPC v10, ha sido muy valorado por la comunidad, y muchas bibliotecas han adoptado patrones similares. Incluso se ha acuñado el término tRPC like XYZ como evidencia de la creciente popularidad de este patrón. De hecho, el otro día vi a alguien preguntándose si había una forma de escribir aplicaciones CLI con una API similar a tRPC. Nota al margen: incluso puedes usar tRPC directamente para hacer esto. Pero no es de eso de lo que venimos a hablar hoy, sino de cómo usar tRPC con server actions de Next.js.

¿Qué es una server action?

Por si vives debajo de una roca y no te has mantenido al día con las últimas características de React y Next.js, las server actions te permiten escribir funciones regulares que se ejecutan en el servidor, importarlas en el cliente y llamarlas como si fueran funciones normales. Podrías pensar que esto suena similar a tRPC, y es cierto. Según Dan Abramov, las server actions son "tRPC como una característica del bundler":

Y esto es totalmente acertado: las server actions son similares a tRPC, al fin y al cabo ambas son RPCs. Ambas te permiten escribir funciones en el backend y llamarlas con total seguridad de tipos en el frontend, con la capa de red abstraída.

Entonces, ¿dónde encaja tRPC? ¿Por qué necesitaría tanto tRPC como server actions? Las server actions son una primitiva, y como todas las primitivas, son bastante básicas y carecen de aspectos fundamentales para construir APIs. Para cualquier endpoint de API expuesto en la red, necesitas validar y autorizar solicitudes para asegurarte de que tu API no se use maliciosamente. Como mencionamos antes, la API de tRPC es apreciada por la comunidad, así que ¿no sería genial si pudiéramos usar tRPC para definir server actions y aprovechar todas las características increíbles que vienen incorporadas en tRPC, como validación de entrada, autenticación y autorización mediante middlewares, validación de salida, transformadores de datos, etc., etc.? Yo creo que sí, así que profundicemos.

Definición de server actions con tRPC

nota

Prerrequisitos: Para usar server actions, necesitas utilizar el App Router de Next.js. Además, todas las funcionalidades de tRPC que usaremos solo están disponibles en tRPC v11, así que asegúrate de usar el canal beta de tRPC:

npm install @trpc/server

Empecemos inicializando tRPC y definiendo nuestro procedimiento base para server actions. Usaremos el método experimental_caller del constructor de procedimientos, un nuevo método que te permite personalizar cómo se llama el procedimiento cuando se invoca como función. También usaremos el adaptador experimental_nextAppDirCaller para hacerlo compatible con Next.js. Este adaptador manejará casos donde la server action está envuelta en useActionState en el cliente, lo cual cambia la firma de llamada de la server action.

También usaremos una propiedad span como metadatos, ya que no hay una ruta ordinaria como cuando usas un router (por ejemplo, user.byId). Puedes usar la propiedad span para diferenciar procedimientos, por ejemplo durante el registro de logs o la observabilidad.

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,
}),
);

A continuación, añadiremos contexto. Como no alojaremos un router usando un adaptador HTTP regular, no tendremos contexto inyectado mediante el método createContext del adaptador. En su lugar, usaremos un middleware para inyectar nuestro contexto. En este ejemplo, obtengamos el usuario actual desde la sesión e inyectémoslo en el contexto.

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 } });
});

Por último, crearemos un procedimiento protectedAction que protegerá cualquier acción de usuarios no autenticados. Si ya tienes un middleware existente que hace esto, puedes usarlo, pero para este ejemplo definiré uno en línea.

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
},
});
});

Muy bien, escribamos una acción de servidor real. Crea un archivo _actions.ts, decóralo con la directiva "use server" y define tu acción.

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 (opts) => {
// 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 (opts) => {
// 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>

¡Vaya! Es así de fácil definir una acción de servidor protegida contra usuarios no autenticados, con validación de entrada para prevenir ataques como inyecciones SQL. Importemos esta función en el cliente y llamémosla.

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>
);
}

Avanzando

Usando los constructores de tRPC y su enfoque componible para definir procedimientos reutilizables, podemos crear fácilmente acciones de servidor más complejas. Aquí algunos ejemplos:

Observabilidad

Puedes usar el plugin de tRPC de @baselime/node-opentelemtry para añadir observabilidad con solo unas líneas de código:

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 (opts) => {
// 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 (opts) => {
// Do something with the input
});

Consulta la Integración de tRPC con Baselime para más información. Patrones similares deberían funcionar con cualquier plataforma de observabilidad que uses.

Limitación de tasa (Rate Limiting)

Puedes usar servicios como Unkey para limitar la tasa de tus acciones de servidor. Este es un ejemplo de acción protegida que usa Unkey para limitar solicitudes por usuario:

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 (opts) => {
console.log(
`${opts.ctx.user.name} commented on ${opts.input.postId} saying ${opts.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 (opts) => {
console.log(
`${opts.ctx.user.name} commented on ${opts.input.postId} saying ${opts.input.content}`,
);
});

Lee más sobre cómo limitar la tasa de tus procedimientos tRPC en esta publicación del equipo de Unkey.

Las posibilidades son infinitas, y seguro ya tienes muchos middlewares útiles en tus aplicaciones tRPC. Si no, ¡quizás encuentres algunos que puedas instalar con npm install!

Conclusión

Las Acciones de Servidor no son una solución mágica. Para datos más dinámicos, conviene mantener datos en la caché de React Query del cliente y hacer mutaciones con useMutation. Esto es totalmente válido. Estas nuevas primitivas permiten adopción incremental: puedes migrar procedimientos individuales de tu API tRPC existente donde tenga sentido, sin reescribir toda la API.

Al definir acciones de servidor con tRPC, reutilizas lógica existente y eliges dónde exponer mutaciones como acciones de servidor o mutaciones tradicionales. Como desarrollador, decides qué patrones funcionan mejor. Si no usas tRPC actualmente, paquetes como next-safe-action o zsa también permiten crear acciones de servidor tipadas y validadas.

Para ver una aplicación usando esto en acción, visita Trellix tRPC, una app que creé recientemente usando estas primitivas.

¿Qué opinas? Queremos tu feedback

¿Qué te parece? Cuéntanos en Github y ayúdanos a llevar estas primitivas a un estado estable.

Queda trabajo pendiente, especialmente en manejo de errores. Next.js recomienda devolver errores, y queremos hacerlo con seguridad de tipos. Mira este PR en progreso de Alex para avances iniciales.

¡Hasta la próxima, feliz codificación!