Llevo un año construyendo aplicaciones con Server Actions de Next.js en producción, desplegadas en Lambda via Amplify Gen 2. Al principio las miré con escepticismo porque cada vez que Vercel saca una feature nueva pienso "genial, otra cosa que solo funciona bien en su plataforma". Pero después de migrar tres proyectos, acepto que Server Actions son genuinamente útiles, incluso fuera de Vercel.
La tesis del artículo es simple: Server Actions te dan formularios que funcionan sin JavaScript, progressive enhancement real, y menos código. En Lambda funcionan perfectamente si entiendes algunos detalles de cómo Next.js los serializa y cómo Lambda los invoca.
El problema que resuelven
Antes de Server Actions, el flujo típico de un formulario en Next.js era:
sequenceDiagram
participant U as Usuario
participant C as Componente Cliente
participant A as API Route
participant D as Base de datos
U->>C: Envía formulario
C->>C: preventDefault
C->>C: Valida con zod
C->>A: fetch POST /api/users
A->>A: Re-valida con zod
A->>D: Insert
D-->>A: Resultado
A-->>C: JSON response
C->>C: Actualiza estado local
C->>C: Muestra toast
C->>C: router.refresh o mutate SWR
Hay tres problemas acá. Primero, duplicas validación en cliente y servidor. Segundo, todo depende de JavaScript cargado, ejecutándose y sin errores. Si el JS falla o tarda en cargar, el formulario no sirve. Tercero, la API Route es un endpoint HTTP que cualquiera puede llamar, así que necesitas CSRF tokens, rate limiting, y auth checks redundantes.
Server Actions eliminan la API Route intermedia:
sequenceDiagram
participant U as Usuario
participant F as Form (server)
participant SA as Server Action (Lambda)
participant D as Base de datos
U->>F: Submit (nativo, sin JS)
F->>SA: POST con form data serializada
SA->>SA: Valida con zod
SA->>D: Insert
D-->>SA: Resultado
SA-->>F: HTML nuevo + redirect
F->>U: Render actualizado
Lo notable: si el usuario tiene JavaScript deshabilitado, bloqueado por una extensión, o simplemente el bundle no cargó todavía, el formulario funciona igual. Next.js hace progressive enhancement automático.
Ejemplo real: formulario de registro
Este es código de producción simplificado de un proyecto real. Es un formulario de registro para una plataforma SaaS.
// app/register/page.tsx
import { register } from "./actions";
import { SubmitButton } from "./submit-button";
export default function RegisterPage() {
return (
<form action={register} className="max-w-md mx-auto space-y-4">
<h1 className="text-2xl font-bold">Crear cuenta</h1>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Contraseña
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium">
Empresa
</label>
<input
id="company"
name="company"
type="text"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<SubmitButton />
</form>
);
}
Nota que no hay onSubmit, no hay useState, no hay fetch. El action del form apunta directamente a una función server. La acción register vive en un archivo separado:
// app/register/actions.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { hash } from "bcryptjs";
import { db } from "@/lib/db";
import { createSession } from "@/lib/session";
import { sendWelcomeEmail } from "@/lib/email";
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
company: z.string().min(2).max(100),
});
export async function register(formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = RegisterSchema.safeParse(raw);
if (!parsed.success) {
return {
error: "Datos inválidos",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const { email, password, company } = parsed.data;
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return { error: "Este email ya está registrado" };
}
const passwordHash = await hash(password, 12);
const user = await db.user.create({
data: {
email,
passwordHash,
company: { create: { name: company } },
},
include: { company: true },
});
const session = await createSession(user.id);
(await cookies()).set("session", session.token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30,
});
await sendWelcomeEmail(user.email, user.company.name);
redirect("/dashboard");
}
La directiva "use server" al tope del archivo marca cada export como Server Action. Next.js genera un endpoint interno que invoca esta función cuando recibe el POST. El hash del archivo es parte de la URL, lo que protege contra invocaciones arbitrarias.
El botón con pending state
Para mostrar feedback visual mientras la acción corre, usas useFormStatus:
// app/register/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-blue-600 text-white rounded py-2 disabled:opacity-50"
>
{pending ? "Creando cuenta..." : "Registrarme"}
</button>
);
}
Este componente es cliente porque usa un hook, pero el form entero sigue funcionando sin JavaScript. Cuando JS está disponible, el botón muestra el estado pending. Cuando no, el navegador hace un submit regular y navega a la página de respuesta.
Validación con useActionState
Para mostrar errores de validación sin perder el estado del formulario, usas useActionState:
// app/register/page.tsx (versión con errores)
"use client";
import { useActionState } from "react";
import { register } from "./actions";
import { SubmitButton } from "./submit-button";
const initialState = { error: null, fieldErrors: {} };
export default function RegisterPage() {
const [state, formAction] = useActionState(register, initialState);
return (
<form action={formAction} className="max-w-md mx-auto space-y-4">
<h1 className="text-2xl font-bold">Crear cuenta</h1>
{state.error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-3 rounded">
{state.error}
</div>
)}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
aria-invalid={!!state.fieldErrors?.email}
/>
{state.fieldErrors?.email && (
<p className="text-sm text-red-600">{state.fieldErrors.email[0]}</p>
)}
</div>
{/* resto de campos */}
<SubmitButton />
</form>
);
}
Cuando usas useActionState, el componente se vuelve cliente pero mantiene progressive enhancement. Sin JS, el submit sigue funcionando aunque sin mostrar los errores inline.
Cómo viajan los Server Actions a Lambda
Acá es donde la cosa se pone interesante para los que desplegamos fuera de Vercel. Cuando Next.js serializa un Server Action, genera un endpoint RPC interno. En Amplify Gen 2 con Lambda, el deploy funciona así:
flowchart LR
A[Build Next.js] --> B[Genera manifest con action IDs]
B --> C[Bundle Lambda handler]
C --> D[Lambda Response Streaming]
D --> E[CloudFront]
F[Cliente submit] --> G[POST /register con header Next-Action]
G --> E
E --> D
D --> H[Next.js server runtime]
H --> I[Ejecuta action]
I --> J[Retorna HTML + redirect]
J --> F
El header clave es Next-Action: <hash>. Next.js lo usa para identificar qué función ejecutar. El Lambda handler de Amplify Gen 2 procesa este header sin modificación.
La configuración del adapter es mínima:
// amplify/backend.ts
import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
const backend = defineBackend({
auth,
data,
});
// Amplify Gen 2 detecta Next.js automáticamente y configura el runtime
// No necesitas configurar nada específico para Server Actions
Lo único que recomiendo es aumentar el timeout del Lambda si tus actions hacen operaciones pesadas:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "5mb",
},
},
};
export default config;
El bodySizeLimit es importante. Por defecto es 1MB, lo que se queda corto si subes archivos vía Server Actions.
Upload de archivos con Server Actions
Este patrón lo uso mucho. Un formulario que sube una imagen directamente a S3:
// app/profile/avatar/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSession } from "@/lib/session";
import { db } from "@/lib/db";
import { randomUUID } from "crypto";
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.AVATARS_BUCKET!;
const AvatarSchema = z.object({
file: z
.instanceof(File)
.refine((f) => f.size < 5 * 1024 * 1024, "Máximo 5MB")
.refine(
(f) => ["image/jpeg", "image/png", "image/webp"].includes(f.type),
"Solo JPG, PNG o WebP"
),
});
export async function uploadAvatar(formData: FormData) {
const session = await getSession();
if (!session) return { error: "No autenticado" };
const parsed = AvatarSchema.safeParse({ file: formData.get("file") });
if (!parsed.success) {
return { error: parsed.error.errors[0].message };
}
const { file } = parsed.data;
const buffer = Buffer.from(await file.arrayBuffer());
const key = `avatars/${session.userId}/${randomUUID()}.${file.type.split("/")[1]}`;
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: file.type,
CacheControl: "public, max-age=31536000, immutable",
})
);
await db.user.update({
where: { id: session.userId },
data: { avatarKey: key },
});
revalidatePath("/profile");
return { success: true, key };
}
El formulario correspondiente:
// app/profile/avatar/form.tsx
"use client";
import { useActionState } from "react";
import { uploadAvatar } from "./actions";
export function AvatarForm() {
const [state, action] = useActionState(uploadAvatar, null);
return (
<form action={action} encType="multipart/form-data">
<input type="file" name="file" accept="image/*" required />
<button type="submit">Subir avatar</button>
{state?.error && <p className="text-red-600">{state.error}</p>}
{state?.success && <p className="text-green-600">Avatar actualizado</p>}
</form>
);
}
El encType="multipart/form-data" es crítico. Sin él, el archivo llega como string vacío.
Optimistic updates con useOptimistic
Para UX pulida, combina Server Actions con useOptimistic:
// app/tasks/task-list.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTask, deleteTask } from "./actions";
type Task = { id: string; title: string; done: boolean };
export function TaskList({ tasks }: { tasks: Task[] }) {
const [optimisticTasks, updateOptimistic] = useOptimistic(
tasks,
(state, action: { type: string; id: string; done?: boolean }) => {
if (action.type === "toggle") {
return state.map((t) =>
t.id === action.id ? { ...t, done: action.done! } : t
);
}
if (action.type === "delete") {
return state.filter((t) => t.id !== action.id);
}
return state;
}
);
const [, startTransition] = useTransition();
return (
<ul>
{optimisticTasks.map((task) => (
<li key={task.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={task.done}
onChange={(e) => {
startTransition(async () => {
updateOptimistic({
type: "toggle",
id: task.id,
done: e.target.checked,
});
await toggleTask(task.id, e.target.checked);
});
}}
/>
<span className={task.done ? "line-through" : ""}>{task.title}</span>
<form
action={async () => {
startTransition(async () => {
updateOptimistic({ type: "delete", id: task.id });
await deleteTask(task.id);
});
}}
>
<button type="submit">Eliminar</button>
</form>
</li>
))}
</ul>
);
}
El checkbox actualiza el UI antes de que el Server Action retorne. Si falla, React revierte automáticamente al estado del servidor.
Revalidación granular
Server Actions se integran con el cache de Next.js. Después de mutar datos, revalidas rutas o tags específicos:
// app/posts/actions.ts
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
const post = await db.post.create({ data: { title, body } });
revalidateTag(`posts`);
revalidatePath(`/posts`);
return { id: post.id };
}
export async function updatePost(id: string, formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.update({ where: { id }, data: { title, body } });
revalidateTag(`post-${id}`);
revalidatePath(`/posts/${id}`);
}
Y en el componente que consume:
// app/posts/[id]/page.tsx
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
async function getPost(id: string) {
return unstable_cache(
async () => db.post.findUnique({ where: { id } }),
[`post-${id}`],
{ tags: [`post-${id}`] }
)();
}
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
return (
<article>
<h1>{post?.title}</h1>
<p>{post?.body}</p>
</article>
);
}
Cuando llamas revalidateTag('post-123'), Next.js invalida el cache solo para ese post. Próxima request regenera solo esa página.
Comparación: API Route vs Server Action
| Aspecto | API Route | Server Action |
|---|---|---|
| Funciona sin JS | No | Sí |
| Código duplicado de validación | Sí (cliente + server) | No (solo server) |
| Protección CSRF | Manual | Automática (origin check) |
| Tipo-safety extremo a extremo | Manual con tRPC o similar | Nativa |
| Upload de archivos | Manual con FormData | Nativo |
| Integración con cache | Manual (revalidatePath) | Nativa |
| Funciona como RPC externo | Sí | No (solo desde la app) |
| Debugging | Network tab estándar | Header Next-Action, más opaco |
El trade-off principal es que los Server Actions son internos. No puedes llamarlos desde una app móvil o un script externo. Para eso sigues necesitando API Routes o AppSync.
Lo que aprendí en producción
1. Cold starts matan si haces imports pesados en el mismo archivo.
Mi primer error fue poner import { PrismaClient } from "@prisma/client" en un archivo con muchas Server Actions. Cada cold start de Lambda cargaba Prisma entero, que pesa 30MB con el client generado. Moví Prisma a un singleton en /lib/db.ts y uso import { db } donde lo necesito. Bundle analyzer confirma que ahora cada action pesa menos de 500KB.
2. Las actions no pueden retornar clases ni funciones.
Next.js serializa el return value para pasarlo al cliente. Solo funciona con datos planos: strings, numbers, booleans, arrays, objects, Dates. Un día retorné una instancia de un error custom y todo se rompió silenciosamente. Ahora siempre retorno { success: boolean, data?: ..., error?: string }.
3. cookies() y headers() son async desde Next 15.
Migrando proyectos viejos, me pegué contra esto muchas veces. Si ves warnings tipo "cookies() should be awaited", agregas el await. TypeScript a veces no los detecta porque los types cambiaron.
4. CSRF está cubierto pero no para cross-origin.
Server Actions validan el header Origin contra el host. Si tienes un dominio embebido en iframes de otros dominios legítimos (tipo embedded widgets), necesitas configurar allowedOrigins en next.config.ts. Si no, las requests fallarán con error genérico.
5. El Lambda timeout es tu enemigo.
Por defecto Amplify Gen 2 pone Lambda con 30 segundos. Si tu Server Action hace algo largo, tipo procesar CSV grande, te cortan. Para esos casos, la action solo encola un job en SQS y retorna. El procesamiento real corre en un Lambda separado, y notificas al usuario vía WebSocket o polling.
Cuándo NO usar Server Actions
Si necesitas un endpoint consumible por terceros, Server Actions no sirven porque la URL es interna y cambia con cada build. Usa API Routes o AppSync.
Si tu app es puramente cliente (extensión de Chrome, Electron, mobile), no puedes usar Server Actions porque no hay servidor Next.js corriendo. Mantén API Routes.
Si tu action es extremadamente sensible en performance y haces 100 por segundo, considera que cada Server Action tiene overhead de React Server Rendering en el return. Para alta frecuencia, una API Route minimal puede ser 3 o 4 veces más rápida.
Si el equipo no conoce React Server Components, adoptar Server Actions sin entender el modelo mental es receta para bugs. Primero aseguren que entienden la separación cliente/servidor.
El próximo artículo cierra el arco. Un caso real de migración de SPA pura a SSR híbrido en AWS, con los números reales de performance, costos y las decisiones que tomé en el camino.
Top comments (0)