DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Server Actions de Next.js en Lambda, formularios sin JavaScript

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
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 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)