Web Design and Engineering

RPC con tipos en Server Actions de Next.js: sin capa de API

Un Server Action ya es una RPC. Así se vuelve type-safe de extremo a extremo y validado en runtime, sin ruta de API ni tipo de respuesta que mantener.

22 de junio de 20266 min de lectura
a bunch of wires that are connected to a server

Desde un componente de React puedes llamar a una función del servidor como llamas a una local: pasas argumentos, lees un valor de retorno y dejas que el compilador revise los dos lados. Eso es lo que da un Server Action en Next.js. Una RPC type-safe con los Server Actions de Next.js significa que la función del servidor y la llamada del cliente comparten un único tipo: sin ruta de API escrita a mano, sin envoltorio de fetch, sin forma de respuesta que mantener sincronizada. La petición de red sigue ahí. Solo dejas de escribirla a mano.

Al terminar esta guía tendrás una función de servidor tipada de extremo a extremo en el editor, que rechaza entradas no válidas en runtime, que comprueba quién la llama antes de ejecutarse y que devuelve errores tipados en lugar de lanzar excepciones. En ese último paso es donde muchos equipos se detienen demasiado pronto, y ahí es donde viven los agujeros de seguridad.

Qué significa type-safe aquí y qué no

Un Server Action es una RPC. El equipo de tRPC lo describe igual: escribes una función en el backend y la llamas desde el frontend con la capa de red oculta. El detalle es que un tipo de TypeScript es solo un contrato en tiempo de compilación. Los tipos se borran antes de que el código se ejecute, así que limitan tu código, nunca a quien envía la petición.

Cada función marcada con 'use server' se convierte en un endpoint HTTP público. Cualquiera puede hacerle una POST con cualquier payload: null, un objeto donde esperabas una cadena, un blob de 10 MB. La action es un endpoint público, y los límites de los componentes, las comprobaciones del cliente y las firmas de las funciones no valen nada ante una petición enviada con curl. Por eso type-safe en el editor y validado en el cable son dos propiedades distintas. El patrón de abajo consigue las dos desde una sola fuente de verdad.

Constrúyelo en cinco pasos

Paso 1: trata la action como un endpoint público

Antes de escribir el cuerpo de la función, acepta lo que es: una ruta POST sin autenticación. Dentro de cada action que toca datos hacen falta tres comprobaciones: validación de la entrada, comprobación de autenticación (salvo que la action sea de verdad pública) y comprobación de autorización sobre propiedad o permisos. Ponlas en la action, no en el componente que la llama. El componente corre en un dispositivo que no controlas.

Paso 2: escribe el contrato como esquema, no como tipo

Define la entrada con un esquema de validación. Zod es la opción habitual; Valibot y ArkType se comportan igual a través de Standard Schema. Un solo esquema da dos cosas a la vez: un tipo estático que el compilador infiere y una comprobación en runtime que el servidor ejecuta en cada llamada. El tipo se infiere del esquema mientras la entrada que llega se valida contra ese mismo esquema, de modo que no pueden separarse.

const profileSchema = z.object({ name: z.string().min(1).max(80), bio: z.string().max(280) })

El esquema se escribe una vez. El componente, la action y la llamada a la base de datos leen todos la misma forma.

Paso 3: envuelve cada action en un único cliente

Cablear validación y autenticación a mano en cada action se desmorona en un mes. Una librería como next-safe-action da un solo action client con middleware .use() componible y un contexto tipado que llega hasta el handler. Conectas autenticación y rate limiting una vez, y cada action construida sobre el cliente lo hereda. El esquema garantiza que parsedInput esté tipado y validado antes de que tu código corra.

export const updateProfile = actionClient.schema(profileSchema).action(async ({ parsedInput, ctx }) => { return db.profile.update(ctx.user.id, parsedInput) })

Paso 4: llámala desde el componente y gestiona los errores de validación

En el cliente llamas a la action mediante un hook. useActionState de React 19 también sirve. Cuando la entrada no pasa el esquema, vuelve al cliente un objeto validationErrors con cada campo fallido y su mensaje, así el formulario muestra los errores en línea sin un segundo viaje por la red. La utilidad flattenValidationErrors() remodela ese objeto a la forma de tu librería de formularios.

const { execute, result } = useAction(updateProfile)

Paso 5: devuelve errores tipados, no los lances

Una excepción lanzada cruza el límite de red como un error genérico del servidor y pierde su tipo en el cliente. Devuelve en su lugar un objeto resultado tipado, con una rama de éxito y una de fallo. Así el componente se ramifica según el resultado con el compilador revisando los dos caminos, y nunca llega un mensaje de excepción en bruto al navegador.

Cómo comprobar que funciona de verdad

Tres pruebas confirman que el contrato aguanta:

  • Prueba en el editor. Cambia el esquema o la forma del valor de retorno. El punto de llamada debe ponerse en rojo antes de guardar. Si no, el tipo no fluye de extremo a extremo.
  • Prueba con curl. Manda una POST con un payload mal formado directo al endpoint de la action, saltándote el formulario. Una action correcta la rechaza con un error de validación. Una rota se cae o escribe basura en la base de datos.
  • Prueba de autenticación. Llama a la action sin sesión. Debe rechazar antes de ejecutar cualquier lógica, porque la puerta vive dentro de la action.

Fallos frecuentes y cómo corregirlos

Fiarse del tipo como guardia. El error más común con diferencia. Un tipo de TypeScript es documentación que el compilador impone a tu código, nada más. Añade el esquema en runtime aunque el tipo parezca a prueba de balas.

Sin autenticación dentro de la action. La autenticación en la página no protege el endpoint. Mueve la comprobación de sesión a la action o a su middleware.

Dejar salir campos de más. Devolver una fila entera de la base de datos manda columnas que el cliente nunca debería ver. Devuelve una forma explícita, o valida también la salida.

Lanzar excepciones para el flujo normal. Reserva las excepciones para fallos reales. Usa un valor de retorno tipado para los resultados previstos, como una validación fallida o un permiso denegado.

Usar las actions para lecturas pesadas. Los Server Actions corren en secuencia y no se agrupan. Un panel que dispara diez lecturas en paralelo mediante actions se sentirá lento.

Cuándo un Server Action es la herramienta equivocada

Los Server Actions encajan con las mutaciones y con páginas que quedan casi estáticas tras la primera carga. Para una interfaz con muchos datos, con paginación, filtros y ordenación que vuelve a pedir datos después de la carga, o para un backend que sirve también a una app móvil, tRPC con TanStack Query encaja mejor: agrupa las llamadas concurrentes en una sola petición y sirve a más de un cliente con type safety completa. La regla honesta es leer gratis desde los server components, mutar con actions type-safe y pasar a tRPC cuando el lado de lectura desarrolla sus propias exigencias.

Para profundizar

El paso de validación de aquí es la misma puerta que cierra la mayoría de las vulnerabilidades de los Server Actions. Recorrimos toda la superficie de ataque en seguridad de los Server Actions de Next.js. Si todavía estás decidiendo qué lógica va en el servidor, el árbol de decisión entre Server y Client Components es la pieza que acompaña a esta.

Foto de Lightsaber Collection en Unsplash

Preguntas frecuentes

¿Necesito Zod si mi Server Action ya está tipado en TypeScript?
Sí. Un tipo de TypeScript solo existe mientras el código compila. Se borra antes de que la función corra, así que limita tu propio código y nada de lo que llega al endpoint desde fuera. Un Server Action es una ruta HTTP pública, y un atacante puede hacer una POST con cualquier payload directo con curl. El esquema de Zod es la comprobación en runtime que el tipo no puede hacer. Mantén los dos: el esquema valida en runtime y el compilador infiere el tipo de ese mismo esquema.
¿Un Server Action es más lento que una llamada de tRPC?
Para una sola mutación, la diferencia es mínima. La brecha aparece bajo carga: los Server Actions corren en secuencia y no se agrupan, así que una pantalla que dispara varias actions a la vez paga un viaje por cada una. tRPC agrupa las llamadas concurrentes en una única petición HTTP. Si tu interfaz lanza muchas lecturas en paralelo tras la carga inicial, ese agrupamiento importa. Para un guardado o una actualización en un formulario, el Server Action es la herramienta más ligera.
¿Puedo reutilizar un Server Action para una app web y una app móvil a la vez?
No de forma limpia. Un Server Action está atado a la ruta de Next.js que lo define y se invoca a través del protocolo del propio framework, no de una API pública estable que puedas llamar desde un cliente nativo. Si una app móvil necesita la misma lógica, exponla con un route handler o un procedimiento de tRPC que ambos clientes puedan llamar, y deja el Server Action como una capa fina sobre la función compartida. La lógica de negocio vive en una función simple; la action y la API son dos puertas hacia ella.
¿Cómo muestro los errores de validación del formulario sin lanzar desde la action?
Devuélvelos en lugar de lanzarlos. Con next-safe-action, un esquema fallido devuelve un objeto validationErrors que lista cada campo y su mensaje, y flattenValidationErrors() lo remodela para tu librería de formularios. En el cliente, useActionState de React 19 o el hook de la librería expone ese resultado, así el formulario pinta los errores en línea. Una excepción lanzada cruzaría el límite como un error genérico del servidor y perdería el detalle por campo, justo lo que un formulario necesita mostrar.

Studio

Empieza un proyecto.

Un partner único para el producto digital que necesitas construir. Producción más rápida, tecnología moderna, costes reducidos. Un equipo, una factura.