Uso de Server Actions con tRPC
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
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
- yarn
- pnpm
- bun
- deno
npm install @trpc/server
yarn add @trpc/server
pnpm add @trpc/server
bun add @trpc/server
deno add npm:@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.tstsimport {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.tstsimport {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 ,}),);
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.tstsimport {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.tstsimport {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 } });});
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.tstsimport {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.tstsimport {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},});});
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.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =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 ;
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 (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
¡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.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>);}
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 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 (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 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 (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.tstsimport {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.tstsimport {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 (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .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 (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!
