Server Actions y los tres errores de siempre
Una Server Action es un endpoint HTTP público con un nombre generado. Tratarla como una llamada a función es la vía por la que se saltan la autorización y la validación.
Una Server Action parece una función. Escribes async function, la marcas con
'use server', se la pasas a un formulario y se ejecuta en el servidor. La
ergonomía es tan buena que uno deja de pensar en lo que ha pasado de verdad.
Lo que ha pasado es que Next.js generó un endpoint HTTP, le puso un identificador opaco y conectó el formulario para que hiciera POST contra él. Cualquiera que pueda leer tu bundle de JavaScript puede llamarlo, con los argumentos que quiera, desde donde quiera.
Todos los errores de abajo salen de olvidar esa frase.
Uno: dar por fiable a quien llama
Esta es la versión que más veces llega a producción:
'use server';
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}El componente solo pinta el botón para el autor, así que -razona uno- solo un
autor puede llamarla. No funciona así. El endpoint existe se haya pintado el
botón o no, e id es lo que mande quien llama.
Autoriza dentro de la action, siempre, contra la sesión y no contra los argumentos:
'use server';
export async function deletePost(id: string) {
const session = await auth();
if (!session) throw new Error('Unauthorised');
// La propiedad se comprueba en la consulta, no se lee y luego se compara:
// dos sentencias dejan una ventana entre la lectura y el borrado.
const deleted = await db.post.deleteMany({
where: { id, authorId: session.user.id },
});
if (deleted.count === 0) throw new Error('Not found');
revalidatePath('/posts');
}La regla que aplicamos en revisión: la primera sentencia de una action es la lectura de la sesión. Si es otra cosa, la action está mal hasta que alguien explique por qué no.
Dos: validar en el componente
La validación de cliente es una comodidad para quien escribe. No es
validación. La action recibe el FormData tal cual llega por el cable y tiene
que parsearlo:
'use server';
import { z } from 'zod';
const Schema = z.object({
email: z.string().email(),
message: z.string().min(20).max(5000),
});
export async function submit(prevState: State, formData: FormData) {
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await sendToInbox(parsed.data);
return { ok: true };
}Devolver los errores en vez de lanzarlos es lo que hace el formulario usable sin JavaScript y mejor con él:
'use client';
import { useActionState } from 'react';
export function ContactForm() {
const [state, action, pending] = useActionState(submit, {});
return (
<form action={action}>
<input name="email" type="email" required />
{state.errors?.email ? <p role="alert">{state.errors.email[0]}</p> : null}
<textarea name="message" required />
{state.errors?.message ? <p role="alert">{state.errors.message[0]}</p> : null}
<button disabled={pending}>{pending ? 'Enviando' : 'Enviar'}</button>
</form>
);
}Ese formulario envía antes de la hidratación, porque es un formulario de verdad que postea a un endpoint de verdad. Aquí la mejora progresiva no es trabajo extra: es lo que te llevas por no pelearte con la plataforma.
Tres: mutar sin invalidar
La escritura funciona, la página sigue mostrando los datos viejos, y alguien abre un ticket de caché. No es un fallo de caché. Nadie avisó a la caché.
revalidatePath('/posts'); // caché de esta ruta, servidor y cliente
revalidateTag('posts'); // todo fetch etiquetado 'posts'Llama a una de las dos en la action, después de escribir. revalidatePath
limpia la caché de servidor de esa ruta y marca como obsoleta la Router
Cache del cliente: por eso una mutación dentro de una action no necesita nada
más, y la misma mutación en un route handler escrito a mano necesita
router.refresh() en el sitio de la llamada. Cuál es cuál conviene saberlo
bien:
las cuatro capas y cuál te ha mordido.
Cuándo no usarlas
Las actions son para mutaciones que la aplicación posee. Son la herramienta equivocada para:
- Cualquier cosa que llame un tercero. Los webhooks necesitan una URL estable y comprobación de firma. Eso es un Route Handler.
- Leer datos. Una action es un POST. Leer ahí te cuesta la caché y no te da nada; pide los datos en el servidor, en el componente.
- Subidas de ficheros de cualquier tamaño. Las actions pasan por la función de servidor, con su límite de cuerpo y su tiempo de ejecución. Firma una URL y sube directo al almacenamiento.
- Todo lo que quieras limitar por IP antes de que llegue a tu código. El middleware y un Route Handler te dan dónde apoyarte.
Lo que comprobamos antes de publicar una action
Cuatro líneas, y cazan casi todo:
- ¿Empieza por la lectura de la sesión?
- ¿Parsea su entrada con un esquema y devuelve errores en lugar de lanzarlos?
- ¿Invalida lo que ha cambiado?
- ¿Está la comprobación de propiedad dentro de la consulta y no como una lectura seguida de una comparación?
Nada de esto es exótico. Es la misma disciplina que necesita cualquier endpoint HTTP, que es justo el tema: eso es una Server Action.
