En el desarrollo web cliente-servidor convencional, ejecutar mutaciones de datos exigía una notable cantidad de código de infraestructura. El desarrollador debía crear un endpoint REST o GraphQL específico, configurar manejadores onSubmit en el cliente, instanciar clientes HTTP, supervisar estados de carga mediante useState, gestionar errores de red y purgar manualmente las cachés del cliente.
En el App Router de Next.js y React 19 se implementa el concepto de Server Actions: funciones asíncronas de servidor que se invocan directamente desde componentes de cliente o se asignan al atributo action de formularios HTML nativos.
Ventajas fundamentales de la arquitectura de Server Actions:
revalidatePath / revalidateTag) y el re-renderizado del árbol de Server Components se consolidan en una sola respuesta HTTP.Las Server Actions se fundamentan en la directiva 'use server'. Esta directiva señala al compilador de Next.js (Turbopack) que la función o módulo en cuestión debe ejecutarse exclusivamente en el servidor.
Cuando se inyecta una Server Action en un Client Component, el compilador excluye el cuerpo de la función del bundle de JavaScript del navegador. En su lugar, genera un identificador criptográfico único (Action ID) e inicializa una referencia proxy ligera en el cliente.
Al ejecutarse la acción, el navegador despacha de forma transparente una petición HTTP POST que incluye la cabecera personalizada Next-Action. El servidor procesa la lógica de negocio y emite la respuesta serializada.
Desde el punto de vista conceptual, las Server Actions constituyen una implementación de la arquitectura RPC, integrada de manera nativa en el árbol de componentes de React.
El usuario envía un formulario o ejecuta la acción programáticamente mediante un transition.
Con JS activo, se intercepta el evento sintético; sin JS, se emite un envío POST nativo de formulario.
El navegador remite la petición POST con el cuerpo FormData y el Action ID en la cabecera.
Next.js valida el origen de la petición para mitigar ataques CSRF de forma automática.
Si las cabeceras Origin y Host difieren, el servidor responde inmediatamente con un código 403 Forbidden.
El servidor descifra los parámetros vinculados vía .bind(), verifica la sesión y ejecuta la consulta SQL.
La invocación de revalidatePath purga la caché y re-renderiza al instante los Server Components afectados.
El cliente recibe el resultado de la acción y el nuevo RSC Flight Payload en el mismo cuerpo de respuesta.
React actualiza los componentes en el DOM de forma suave sin requerir recargar la página.
Las Server Actions pueden estructurarse de dos maneras:
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { revalidatePath } from 'next/cache';
export default function PostsPage() {
// Función asíncrona dentro de un Server Component
async function createPost(formData: FormData): Promise<void> {
'use server'; // La directiva al inicio del cuerpo marca la función como Server Action
const title = formData.get('title');
if (typeof title !== 'string' || !title.trim()) {
return;
}
await db.insert(posts).values({ title });
// Invalida la caché para reflejar de inmediato la nueva publicación
revalidatePath('/posts');
}
return (
<main className="p-6">
<form action={createPost} className="flex gap-2">
<input type="text" name="title" required className="border p-2 rounded" />
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Crear publicación
</button>
</form>
</main>
);
}Para consumir acciones dentro de Client Components ('use client'), estas deben residir obligatoriamente en un archivo separado con la directiva 'use server' en su encabezado:
'use server'; // Aplica a todas las funciones exportadas en este módulo
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
export async function deletePostAction(postId: string): Promise<{ success: boolean }> {
await db.delete(posts).where(eq(posts.id, postId));
revalidatePath('/posts');
return { success: true };
}Para coordinar el estado de un formulario, gestionar fallos de validación y mostrar estados de carga en React 19, se emplea el hook useActionState.
'use client';
import { useActionState } from 'react';
import { createUserAction, type FormState } from '@/app/actions/users';
const initialState: FormState = {
success: false,
message: '',
errors: undefined,
};
export function UserForm() {
// state — estado actual devuelto por la acción
// formAction — función intermediaria suministrada a <form action={...}>
// isPending — bandera booleana que refleja la ejecución de la petición
const [state, formAction, isPending] = useActionState(createUserAction, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="email" className="block text-sm font-medium">Correo electrónico</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded p-2"
/>
{state.errors?.email && (
<p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{isPending ? 'Guardando...' : 'Registrar usuario'}
</button>
{state.message && (
<p className={`text-sm ${state.success ? 'text-green-600' : 'text-red-600'}`}>
{state.message}
</p>
)}
</form>
);
}El hook useFormStatus de react-dom permite a los componentes descendientes consultar el estado de envío del formulario contenedor sin necesidad de props:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label = 'Enviar' }: { label?: string }) {
// Lee el contexto del formulario <form> ancestro más cercano
const { pending, data, method } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 rounded bg-accent-cyan text-black font-semibold disabled:opacity-50 transition-opacity"
>
{pending ? 'Procesando...' : label}
</button>
);
}El hook useOptimistic permite proyectar modificaciones de estado en la UI de inmediato antes de que el servidor confirme la petición:
'use client';
import { useOptimistic, startTransition } from 'react';
import { likePostAction } from '@/app/actions/posts';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentLikes: number, increment: number) => currentLikes + increment
);
const handleLike = () => {
startTransition(async () => {
// 1. Reflejar inmediatamente el cambio en el cliente de forma optimista
addOptimisticLike(1);
// 2. Emitir la petición al servidor
await likePostAction(postId);
});
};
return (
<button
type="button"
onClick={handleLike}
className="border border-border px-3 py-1.5 rounded-lg hover:border-accent-cyan"
>
Me gusta: {optimisticLikes}
</button>
);
}¿Por qué invocar redirect('/dashboard') dentro de un bloque try { ... redirect('/dashboard') } catch (err) { ... } en una Server Action detiene la navegación?
.bind()#Al enviarse, un formulario HTML entrega únicamente el objeto FormData. Si la acción precisa de un parámetro contextual complementario (como el identificador de un registro), se recurre a .bind():
'use client';
import { deleteUserAction } from '@/app/actions/users';
interface UserDTO {
id: string;
name: string;
}
interface UserListProps {
user: UserDTO;
}
export function UserItem({ user }: UserListProps) {
// Vinculamos el argumento userId como primer parámetro de la Server Action
const deleteWithUserId = deleteUserAction.bind(null, user.id);
return (
<div className="flex items-center justify-between p-3 border rounded">
<span>{user.name}</span>
<form action={deleteWithUserId}>
<button type="submit" className="text-red-500 hover:underline">
Eliminar
</button>
</form>
</div>
);
}[!IMPORTANT] Las Server Actions son endpoints RPC públicos. Toda acción exportada puede invocarse directamente mediante peticiones HTTP POST desde herramientas externas (cURL, Postman). Ocultar un botón en la interfaz de usuario no protege la acción. La verificación de sesión y permisos (RBAC) es estrictamente obligatoria en la primera línea de cada Server Action.
'use server';
import { cookies } from 'next/headers';
import { verifySessionToken } from '@/lib/auth';
export async function executeAdminTask(data: FormData): Promise<{ success: boolean; error?: string }> {
// Lectura asíncrona de cookies conforme al estándar de Next.js 15+
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) {
return { success: false, error: 'Unauthorized: Sesión inexistente' };
}
const session = await verifySessionToken(token);
if (!session || session.role !== 'admin') {
return { success: false, error: 'Forbidden: Privilegios insuficientes' };
}
// Lógica de negocio del administrador...
return { success: true };
}Un Server Component recupera de la BD un usuario con 150 campos (incluyendo passwordHash). Dentro del componente se define una acción en línea: async function update() { 'use server'; await db.save(user.id); }. ¿Qué riesgo de seguridad se suscita?
La función redirect() de next/navigation arroja intencionadamente una excepción interna (NEXT_REDIRECT) para abortar el renderizado en curso y despachar la cabecera de redirección.
Si se encierra redirect() dentro de un bloque try/catch convencional, la excepción se confunde con un error de aplicación, abortando la navegación:
Solución: Sitúa siempre las llamadas a redirect() después del bloque try/catch.
// ❌ ERROR: redirect es interceptado por el bloque catch
'use server';
import { redirect } from 'next/navigation';
export async function createPostAction(prevState: unknown, formData: FormData) {
try {
await db.insert(posts).values({ title: formData.get('title') });
redirect('/posts'); // ¡Arroja la excepción NEXT_REDIRECT!
} catch (err: unknown) {
// ¡La redirección se captura aquí, mostrando un error en vez de navegar!
return { error: 'No se pudo crear la publicación' };
}
}
// ✅ CORRECTO: Invocar redirect fuera del bloque try/catch
export async function createPostAction(prevState: unknown, formData: FormData) {
try {
await db.insert(posts).values({ title: formData.get('title') });
} catch (err: unknown) {
return { error: 'No se pudo crear la publicación' };
}
redirect('/posts'); // Se ejecuta limpiamente fuera del interceptor
}Una Server Action señalada con 'use server' está disponible para cualquier cliente que conozca su Action ID. Aunque un botón se oculte condicionalmente en la interfaz para usuarios corrientes, un usuario malintencionado puede enviar una petición POST directa con la cabecera Next-Action y ejecutar la operación.
Solución: Los controles de acceso (RBAC) deben validarse dentro de la función en el servidor antes de cualquier modificación.
Al declarar Server Actions en línea dentro de Server Components, el compilador cifra y envía al navegador todas las variables del componente padre capturadas en el ámbito.
Solución: Declara las acciones en módulos dedicados actions.ts y suministra únicamente los valores primitivos necesarios mediante .bind().
// ❌ PELIGROSO: Captura el objeto heavyUser completo con información privada
export default async function ProfilePage() {
const heavyUser = await db.getUser(); // Contiene passwordHash, secretKeys, 100 atributos
async function updateStatus() {
'use server';
// Aunque solo se emplee el id, ¡Next.js cifrará y enviará al cliente TODO el objeto heavyUser!
await db.updateUserStatus(heavyUser.id, 'online');
}
return <form action={updateStatus}><button>Conectar</button></form>;
}
// ✅ CORRECTO: Exporta las acciones en actions.ts y envía únicamente IDs escalares
// app/actions.ts:
// export async function updateStatus(userId: string) { 'use server'; ... }