Server Actions —llamadas actualmente Server Functions en la documentación de React y Next.js, cuando se usan para mutaciones— permiten ejecutar una función del servidor desde un formulario o componente sin diseñar manualmente un endpoint para cada interacción.
No significan “el fin de REST”. Resuelven otro problema: simplificar operaciones que pertenecen a una interfaz concreta y no necesitan convertirse en una API pública o compartida.
Qué ocurre realmente
Una Server Action sigue siendo una operación de red. Next.js la invoca mediante una petición POST; por eso debes autenticar, autorizar y validar igual que en cualquier endpoint.
sequenceDiagram
actor U as Usuario
participant F as Formulario React
participant A as Server Action
participant D as Base de datos
participant C as Caché de Next.js
U->>F: Envía formulario
F->>A: POST con FormData
A->>A: Autentica, autoriza y valida
A->>D: Ejecuta mutación
D-->>A: Resultado
A->>C: Revalida ruta o etiqueta
A-->>F: Estado actualizado
La ventaja es de integración: el framework coordina la mutación, la actualización del árbol de UI y el progressive enhancement del formulario.
Cuándo usar una Server Action
Es una buena candidata cuando:
- La operación nace en una interfaz Next.js concreta.
- El formulario y la mutación evolucionan juntos.
- No necesitas exponer un contrato para móviles, terceros o webhooks.
- Quieres aprovechar
useActionState, estados pendientes y revalidación. - La respuesta principal es actualizar o redirigir la propia UI.
Mantén una API o handler explícito cuando:
- Existen varios consumidores.
- Necesitas versionado, documentación OpenAPI o rate limiting independiente.
- Recibes webhooks o integraciones máquina a máquina.
- El cliente sube o descarga streams grandes.
- El contrato debe vivir separado del despliegue del frontend.
| Pregunta | Server Action | API explícita |
|---|---|---|
| ¿Quién consume? | Una UI Next.js | Varias aplicaciones o terceros |
| ¿Contrato público? | No | Sí |
| ¿Versionado independiente? | Rara vez | Frecuente |
| ¿Progressive enhancement? | Integrado con formularios | Debes implementarlo |
| ¿Control HTTP detallado? | Menor | Mayor |
Ejemplo completo: actualizar un perfil
1. Define un estado serializable
La Action debe devolver datos que el cliente pueda representar sin interpretar excepciones internas:
// app/profile/state.ts
export type ProfileState = {
status: 'idle' | 'error' | 'success';
message?: string;
errors?: {
name?: string[];
bio?: string[];
};
};
export const initialState: ProfileState = { status: 'idle' };
2. Autentica, autoriza y valida dentro de la Action
Nunca aceptes un userId del formulario como prueba de identidad. Obtén el usuario desde la sesión y verifica el permiso en el servidor.
// app/profile/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import type { ProfileState } from './state';
const profileSchema = z.object({
name: z.string().trim().min(2, 'Usa al menos 2 caracteres').max(80),
bio: z.string().trim().max(280, 'La biografía admite hasta 280 caracteres'),
});
export async function updateProfile(
_previousState: ProfileState,
formData: FormData,
): Promise<ProfileState> {
const session = await auth();
if (!session?.user?.id) {
return { status: 'error', message: 'Debes iniciar sesión' };
}
const parsed = profileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio'),
});
if (!parsed.success) {
return {
status: 'error',
message: 'Revisa los campos marcados',
errors: parsed.error.flatten().fieldErrors,
};
}
await db.user.update({
where: { id: session.user.id },
data: parsed.data,
});
revalidatePath('/profile');
return { status: 'success', message: 'Perfil actualizado' };
}
Este orden importa:
- Autenticación: ¿quién hace la solicitud?
- Autorización: ¿puede modificar este recurso?
- Validación: ¿la entrada tiene una forma aceptable?
- Regla de negocio: ¿la transición está permitida?
- Persistencia: ¿cómo se guarda?
- Revalidación o redirección: ¿qué debe ver ahora el usuario?
3. Representa los estados del formulario
// app/profile/profile-form.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { updateProfile } from './actions';
import { initialState } from './state';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Guardando…' : 'Guardar cambios'}
</button>
);
}
export function ProfileForm({
defaultName,
defaultBio,
}: {
defaultName: string;
defaultBio: string;
}) {
const [state, formAction] = useActionState(updateProfile, initialState);
return (
<form action={formAction}>
<label>
Nombre
<input name="name" defaultValue={defaultName} aria-describedby="name-error" />
</label>
<p id="name-error">{state.errors?.name?.[0]}</p>
<label>
Biografía
<textarea name="bio" defaultValue={defaultBio} aria-describedby="bio-error" />
</label>
<p id="bio-error">{state.errors?.bio?.[0]}</p>
<SubmitButton />
{state.message ? <p aria-live="polite">{state.message}</p> : null}
</form>
);
}
useFormStatus debe ejecutarse desde un componente hijo del formulario para leer correctamente su estado pendiente.
Revalidar no es lo mismo que redirigir
Después de una mutación tienes tres caminos habituales:
- Devolver estado si el usuario permanece en el formulario.
- Usar
revalidatePathorevalidateTagsi cambió información cacheada. - Usar
redirectsi el flujo termina en otra pantalla.
await db.article.create({ data: article });
revalidatePath('/articles');
redirect(`/articles/${article.slug}`);
No revalides toda la aplicación por comodidad. Invalida la unidad más pequeña que represente los datos modificados.
Errores esperados y errores inesperados
Un campo inválido, un nombre duplicado o una operación no permitida son resultados esperables. Devuélvelos como estado para que la interfaz pueda ayudar al usuario.
Una caída de la base de datos o un bug son errores inesperados. Regístralos con contexto en el servidor y deja que el límite de error de la ruta muestre una respuesta segura.
flowchart TD
E["Resultado de la Action"] --> Q{"¿Era previsible?"}
Q -->|"Sí"| S["Devolver estado y mensaje accionable"]
Q -->|"No"| L["Registrar error con contexto"]
L --> B["Error boundary / respuesta genérica"]
Evita devolver mensajes internos, queries o stack traces al navegador.
Seguridad: la lista que no debes omitir
- Trata la Action como un endpoint público. Puede recibir peticiones directas.
- Autentica dentro de cada operación sensible.
- Autoriza sobre el recurso, no solo sobre el rol general.
- Valida todos los campos en el servidor, aunque el HTML use
required. - No confíes en inputs ocultos para identidad o permisos.
- Limita frecuencia y tamaño en operaciones costosas.
- Evita filtrar secretos en errores o valores de retorno.
- Haz idempotentes las operaciones que puedan repetirse por reintentos.
Mantén la lógica de negocio fuera de la Action
Una Server Action es un adaptador de entrada, no una arquitectura completa. Si la regla se usa desde más de un lugar, extráela:
export async function updateProfile(
_state: ProfileState,
formData: FormData,
): Promise<ProfileState> {
const actor = await requireUser();
const input = parseProfile(formData);
const result = await profileService.update({ actor, input });
if (!result.ok) {
return toProfileState(result.error);
}
revalidatePath('/profile');
return { status: 'success', message: 'Perfil actualizado' };
}
Así puedes probar la política y el caso de uso sin depender de React o Next.js.
Pruebas que aportan valor
- Unidad: validación y reglas del servicio.
- Integración: Action con sesión y repositorio controlados.
- E2E: envío real del formulario, estado pendiente, errores y resultado visible.
No centres las pruebas en comprobar que existe 'use server'. Comprueba que un usuario no autorizado no puede mutar el recurso, que los datos inválidos no se guardan y que la UI comunica el resultado.
Checklist de decisión
Usa una Server Action si puedes responder “sí” a estas preguntas:
- La mutación pertenece principalmente a esta interfaz Next.js.
- No necesito un contrato consumido por terceros.
- Autenticación y autorización ocurren dentro de la operación.
- La entrada se valida en servidor.
- Los errores esperados se representan como estado.
- La revalidación está limitada a los datos afectados.
- La lógica reusable vive fuera del adaptador.
La decisión correcta no es “Server Actions o APIs”. Una aplicación puede usar ambas: Actions para flujos locales de UI y APIs explícitas para contratos compartidos.