Cambiar tema

Tutorial de Next.js Server Actions: mejores prácticas para formularios y validación

Easton editorial illustration: API gateway workstation

Estoy delante del código de un formulario de registro. Cuatro archivos en la carpeta: componente, API Route, tipos, manejo de errores… casi 200 líneas solo para enviar un formulario sencillo.

¿Hay una forma más simple?

Server Actions, la función del App Router de Next.js, puede simplificar ese flujo en un 80%. Sin API Route, sin fetch manual, sin tanto estado en el cliente. Suena bien, pero es normal preguntarse: ¿es seguro? ¿Cómo validar? ¿Y el loading?

Al principio tuve las mismas dudas. Tras unos meses de uso, con algún tropiezo y lecciones aprendidas, quiero compartir contigo trucos reales para formularios con Next.js Server Actions: desde el envío básico hasta Zod, seguridad y UX. Con ejemplos de código reales para que domines esta función rápido.

Fundamentos de Server Actions

¿Qué son las Server Actions?

Las Server Actions son funciones asíncronas que se ejecutan en el servidor. Las marcas con 'use server' y las usas directamente en el atributo action del formulario. Al enviar, se llama automáticamente: procesamiento, base de datos, caché… todo en el servidor.

Características clave:

  • Type-safe: TypeScript comprueba toda la cadena
  • Cero configuración: no hace falta carpeta /api
  • Manejo automático: FormData llega solo

Hay dos estilos: inline en el componente o en un archivo aparte (recomendado):

// Opción 1: inline en el componente
export default function Page() {
  async function createUser(formData: FormData) {
    'use server' // marca como Server Action
    const name = formData.get('name')
    // procesar datos...
  }

  return <form action={createUser}>...</form>
}

// Opción 2: archivo independiente (recomendado)
// app/actions.ts
'use server' // marca a nivel de archivo

export async function createUser(formData: FormData) {
  const name = formData.get('name')
  // procesar datos...
}

¿En qué se diferencian de las API Routes tradicionales? ¿Cuándo usar cada una?

Aquí va una tabla comparativa:

CaracterísticaServer ActionsAPI Routes
UsoEnvío de formularios, mutacionesAPI REST, llamadas externas
Métodos HTTPSolo POSTGET/POST/PUT/DELETE, etc.
Type-safeNativoTipos manuales
InvocaciónLlamada directa a funciónfetch
EscenarioLógica interna, formulariosAPI pública, integraciones
CódigoMenosMás

En resumen: interno → Server Actions; externo → API Routes. Para formularios de tu app, Server Actions bastan. Si necesitas API para otros sistemas o peticiones GET, sigue siendo API Routes.

Según la encuesta de Vercel de 2025, el 63% de los desarrolladores ya usa Server Actions en producción. Ya no es experimental.

"El 63% de los desarrolladores ya usa Server Actions en producción"

Primer ejemplo de Server Actions

Código mínimo: un formulario de login:

// app/login/page.tsx
export default function LoginPage() {
  async function handleLogin(formData: FormData) {
    'use server' // función de servidor

    const email = formData.get('email') as string
    const password = formData.get('password') as string

    console.log('Intento de login:', email)

    // en un proyecto real: validar usuario, generar token, etc.
  }

  return (
    <form action={handleLogin}>
      <input
        type="email"
        name="email"
        placeholder="Correo"
        required
      />
      <input
        type="password"
        name="password"
        placeholder="Contraseña"
        required
      />
      <button type="submit">Iniciar sesión</button>
    </form>
  )
}

Así de simple. Puntos clave:

  1. 'use server': Next.js ejecuta la función en el servidor
  2. formData.get(): obtiene valores por el atributo name
  3. action={handleLogin}: al enviar, se invoca automáticamente

Al pulsar enviar, la página no se recarga; los datos van al servidor. Menos fetch, useState y manejo de errores que con el enfoque clásico.

Pero esto es lo básico. En un proyecto real necesitas validación, errores visibles y loading. Sigue leyendo.

Validación de formularios en la práctica

Validación con Zod

¿Confiar solo en required en el cliente? Ingenuo. Cualquiera puede saltárselo con las herramientas del navegador. La validación en servidor es obligatoria.

Ahí entra Zod: valida en servidor, devuelve errores al instante y evita datos basura en la base de datos.

Instala Zod:

npm install zod

Define reglas:

// app/actions.ts
'use server'

import { z } from 'zod'

const SignupSchema = z.object({
  name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
  email: z.string().email('Formato de correo inválido'),
  password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
})

export async function signup(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  }

  const result = SignupSchema.safeParse(rawData)

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  const { name, email, password } = result.data

  console.log('Crear usuario:', { name, email })

  return {
    success: true,
    message: '¡Registro exitoso!',
  }
}

Puntos clave:

  1. safeParse no lanza: devuelve { success: false, error: ... } y puedes manejarlo con calma
  2. flatten().fieldErrors: errores como { name: ['error1'], email: ['error2'] }, fáciles de mostrar
  3. Respuesta estructurada: success y errores para que el cliente decida qué mostrar

¿Cómo mostrar esos errores en el formulario? Con useActionState.

Mostrar errores de validación: useActionState

useActionState es un Hook de React 19 (antes useFormState) para el estado que devuelven las Server Actions. Hace tres cosas:

  • Guarda la respuesta del servidor en el estado del componente
  • Expone una action envuelta
  • Indica si el formulario se está enviando

Código:

// app/signup/page.tsx
'use client'

import { useActionState } from 'react'
import { signup } from '@/app/actions'

export default function SignupPage() {
  const initialState = { success: false, errors: {}, message: '' }

  const [state, formAction, isPending] = useActionState(signup, initialState)

  return (
    <form action={formAction}>
      <div>
        <label>Nombre</label>
        <input
          type="text"
          name="name"
          required
        />
        {state.errors?.name && (
          <p className="error">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label>Correo</label>
        <input
          type="email"
          name="email"
          required
        />
        {state.errors?.email && (
          <p className="error">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label>Contraseña</label>
        <input
          type="password"
          name="password"
          required
        />
        {state.errors?.password && (
          <p className="error">{state.errors.password[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Enviando...' : 'Registrarse'}
      </button>

      {state.success && (
        <p className="success">{state.message}</p>
      )}
    </form>
  )
}

Flujo:

  1. Usuario envía → signup
  2. Validación falla → { success: false, errors: {...} }
  3. useActionState guarda el resultado en state
  4. Re-render y errores visibles

isPending es true mientras se envía; sirve para deshabilitar el botón o mostrar texto de carga.

Nota: tras un error, los valores del usuario pueden perderse. Puedes devolver values y usar defaultValue; aquí no lo desarrollamos. Lo importante: useActionState conecta cliente y Server Actions y simplifica el estado.

Optimización de la experiencia de usuario

Loading y envíos duplicados

Arriba usamos isPending para loading. También existe useFormStatus. Al principio me confundían.

En corto:

  • isPending de useActionState: dentro del componente del formulario
  • pending de useFormStatus: en un hijo del formulario (p. ej. el botón)

useFormStatus solo funciona dentro de un hijo de <form>, no en el mismo componente del formulario. Parece incómodo, pero permite extraer un botón reutilizable.

Ejemplo con botón aparte:

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className={pending ? 'loading' : ''}
    >
      {pending ? 'Enviando...' : children}
    </button>
  )
}

En el formulario:

// app/signup/page.tsx
'use client'

import { useActionState } from 'react'
import { signup } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'

export default function SignupPage() {
  const [state, formAction] = useActionState(signup, { success: false, errors: {} })

  return (
    <form action={formAction}>
      {/* campos... */}

      <SubmitButton>Registrarse</SubmitButton>

      {state.errors?.general && (
        <p className="error">{state.errors.general}</p>
      )}
    </form>
  )
}

El loading del botón queda encapsulado. Al enviar:

  • el botón se deshabilita (evita doble envío)
  • el texto pasa a «Enviando…»
  • puedes añadir un spinner

¿Diferencia entre pending e isPending?

CaracterísticaisPending (useActionState)pending (useFormStatus)
DóndeDentro del formularioEn un hijo del formulario
EscenarioEstado global del formularioBotón reutilizable
Flexibilidadstate + pendingsolo pending

En proyectos reales:

  • formulario complejo → useActionState
  • botón genérico de envío → useFormStatus

Mejora progresiva

Las Server Actions admiten mejora progresiva: aunque JavaScript esté desactivado, el formulario puede enviarse.

Siguen siendo envíos nativos de <form>. Con JS, Next.js intercepta y hace AJAX; sin JS, envío clásico.

¿Casos reales? Pocos hoy. Pero suma en accesibilidad y crawlers. Y no tienes que hacer nada extra: Next.js lo gestiona.

Seguridad y mejores prácticas

Seguridad de las Server Actions

Es la parte que más se ignora. Muchos piensan: «corre en servidor, así que es seguro». Error grave.

Una Server Action es un endpoint público. Next.js le asigna un ID difícil de adivinar, pero eso es ofuscación, no seguridad real. Con las herramientas de red del navegador se puede ver el ID y llamar la action a mano.

Protecciones integradas de Next.js:

  1. CSRF: solo POST; comprueba que Origin y Host coincidan
  2. ID de action: cifrado, difícil de enumerar
  3. Variables de closure: cifradas si las usas en la action

No basta. Debes hacer esto:

1. Validación de entrada

Nunca confíes en el cliente. Zod en servidor, como ya vimos.

2. Autenticación

Comprueba que el usuario ha iniciado sesión en cada action que lo requiera.

3. Autorización

Iniciar sesión ≠ tener permiso. El usuario A no debe borrar datos del usuario B.

Ejemplo completo:

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { z } from 'zod'

const DeletePostSchema = z.object({
  postId: z.string().min(1),
})

export async function deletePost(formData: FormData) {
  const rawData = {
    postId: formData.get('postId'),
  }

  const result = DeletePostSchema.safeParse(rawData)
  if (!result.success) {
    return { success: false, error: 'Solicitud inválida' }
  }

  const { postId } = result.data

  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value

  if (!sessionToken) {
    return { success: false, error: 'Inicia sesión primero' }
  }

  const currentUser = await getUserFromSession(sessionToken)
  if (!currentUser) {
    return { success: false, error: 'Sesión expirada' }
  }

  const post = await getPost(postId)
  if (!post) {
    return { success: false, error: 'El artículo no existe' }
  }

  if (post.authorId !== currentUser.id) {
    return { success: false, error: 'No tienes permiso para eliminar este artículo' }
  }

  await deletePostFromDB(postId)

  return { success: true, message: 'Eliminado correctamente' }
}

Flujo: validación → autenticación → autorización → operación. Ningún paso sobra.

Herramienta útil: next-safe-action, con middleware para validación, auth y errores:

import { createSafeActionClient } from 'next-safe-action'

const actionClient = createSafeActionClient({
  async middleware() {
    const session = await getSession()
    if (!session) {
      throw new Error('No has iniciado sesión')
    }
    return { userId: session.userId }
  },
})

export const deletePost = actionClient
  .schema(DeletePostSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { postId } = parsedInput
    const { userId } = ctx

    // eliminar...
  })

Todas las actions autenticadas comparten la misma lógica.

Recuerda: una Server Action es un endpoint API. Las mismas medidas de seguridad que en cualquier API.

Caso práctico: formulario con autenticación

Formulario de comentarios solo para usuarios con sesión:

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const CommentSchema = z.object({
  postId: z.string(),
  content: z.string().min(1, 'El comentario no puede estar vacío').max(500, 'Máximo 500 caracteres'),
})

export async function addComment(formData: FormData) {
  const rawData = {
    postId: formData.get('postId'),
    content: formData.get('content'),
  }

  const result = CommentSchema.safeParse(rawData)
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value

  if (!sessionToken) {
    return {
      success: false,
      error: 'Inicia sesión para comentar',
    }
  }

  const user = await getUserFromSession(sessionToken)
  if (!user) {
    return {
      success: false,
      error: 'Sesión expirada, vuelve a iniciar sesión',
    }
  }

  const { postId, content } = result.data

  await saveComment({
    postId,
    content,
    authorId: user.id,
    authorName: user.name,
    createdAt: new Date(),
  })

  revalidatePath(`/posts/${postId}`)

  return {
    success: true,
    message: 'Comentario publicado',
  }
}

Componente cliente:

// app/posts/[id]/CommentForm.tsx
'use client'

import { useActionState } from 'react'
import { addComment } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'

export function CommentForm({ postId }: { postId: string }) {
  const [state, formAction] = useActionState(addComment, {
    success: false,
    errors: {},
  })

  return (
    <form action={formAction}>
      <input type="hidden" name="postId" value={postId} />

      <textarea
        name="content"
        placeholder="Escribe tu comentario..."
        rows={4}
        required
      />

      {state.errors?.content && (
        <p className="error">{state.errors.content[0]}</p>
      )}

      {state.error && (
        <p className="error">{state.error}</p>
      )}

      {state.success && (
        <p className="success">{state.message}</p>
      )}

      <SubmitButton>Publicar comentario</SubmitButton>
    </form>
  )
}

Resume todo lo anterior: Zod, sesión, useActionState, revalidatePath, loading en el botón. Flujo listo para producción.

Técnicas avanzadas

Pasar parámetros extra

A veces necesitas más que los campos del formulario, p. ej. el ID al editar un artículo.

Opción 1: campo oculto

<input type="hidden" name="postId" value={postId} />

Opción 2 más limpia: bind

// app/actions.ts
'use server'

export async function updatePost(postId: string, formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await updatePostInDB(postId, { title, content })

  return { success: true }
}

Cliente:

// app/posts/[id]/edit/page.tsx
'use client'

import { updatePost } from '@/app/actions'

export default function EditPost({ postId }: { postId: string }) {
  const updatePostWithId = updatePost.bind(null, postId)

  return (
    <form action={updatePostWithId}>
      <input type="text" name="title" required />
      <textarea name="content" required />
      <button type="submit">Actualizar</button>
    </form>
  )
}

bind(null, postId) fija el primer argumento; FormData llega como segundo.

Ideal para editar, eliminar u operaciones con ID.

Revalidación de datos

Tras mutar datos, la caché de páginas relacionadas puede quedar obsoleta. Next.js ofrece:

1. revalidatePath

Por ruta:

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  // crear artículo...

  revalidatePath('/')
  revalidatePath(`/posts/${newPostId}`)

  return { success: true }
}

2. revalidateTag

Por etiqueta (hay que etiquetar en fetch):

fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
})

import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  // crear artículo...

  revalidateTag('posts')

  return { success: true }
}

¿Cuándo cuál?

  • Rutas fijas y pocasrevalidatePath
  • Datos en muchas páginasrevalidateTag

Suelo usar primero revalidatePath; revalidateTag cuando una acción afecta muchas vistas.

Actualizaciones optimistas

Operaciones casi siempre exitosas (me gusta, favoritos): actualiza la UI al instante y envía en segundo plano.

React 19 trae useOptimistic:

'use client'

import { useOptimistic } from 'react'
import { likePost } from '@/app/actions'

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, setOptimisticLikes] = useOptimistic(initialLikes)

  async function handleLike() {
    setOptimisticLikes(optimisticLikes + 1)

    await likePost(postId)
  }

  return (
    <button onClick={handleLike}>
      👍 {optimisticLikes}
    </button>
  )
}

El número sube al clic, sin esperar al servidor.

Solo en operaciones muy fiables. Si falla a menudo, revertir la UI empeora la experiencia.

Conclusión

Tres ideas finales:

  1. Server Actions simplifican formularios, pero no lo son todo. Interno → Server Actions; API externa → Route Handlers. No uses Server Actions para todo.

  2. La seguridad es tuya. El framework da lo básico; validación, auth y permisos no son opcionales.

  3. Los detalles de UX importan. Loading, errores, optimismo… marcan la diferencia entre «funciona» y «se siente bien». Combina useActionState y useFormStatus.

Empieza con un formulario simple: una Server Action, Zod y un loading. Con eso cubres el 80%. El 20% restante (caché, optimismo) lo consultas cuando lo necesites.

Next.js y React evolucionan rápido; la API de Server Actions puede cambiar. Revisa la documentación oficial para no quedarte atrás.

Pruébalo en tu proyecto. La próxima vez que escribas un envío de formulario, puede que descubras que puede ser mucho más simple.

Flujo completo para manejar formularios con Server Actions

Pasos completos desde crear el Server Action hasta validación y gestión de estado

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Crear un Server Action

    Crea el Server Action en app/actions.ts:

    1. Marca a nivel de archivo: añade 'use server' al inicio
    2. Define la función: export async function actionName(formData: FormData)
    3. Obtén datos: usa formData.get('fieldName') para cada campo
    4. Devuelve resultado: formato { success: boolean, errors?: {}, message?: string }

    Ejemplo:
    ```typescript
    'use server'
    export async function signup(formData: FormData) {
    const name = formData.get('name') as string
    // lógica...
    return { success: true, message: 'Registro exitoso' }
    }
    ```
  2. 2

    Step 2: Añadir validación con Zod

    Valida en el servidor con Zod:

    1. Instala Zod: npm install zod
    2. Define el schema: const SignupSchema = z.object({ name: z.string().min(2), email: z.string().email() })
    3. Valida: const result = SignupSchema.safeParse(rawData)
    4. Maneja errores: if (!result.success) return { success: false, errors: result.error.flatten().fieldErrors }

    Puntos clave:
    • safeParse no lanza excepciones; devuelve { success, data/error }
    • flatten().fieldErrors convierte errores a { field: ['error1'] }
    • Si falla la validación, devuelve errores estructurados para el cliente
  3. 3

    Step 3: Gestionar estado con useActionState

    En el componente cliente usa useActionState:

    1. Importa: import { useActionState } from 'react'
    2. Estado inicial: const initialState = { success: false, errors: {} }
    3. Hook: const [state, formAction, isPending] = useActionState(action, initialState)
    4. Enlaza: <form action={formAction}>
    5. Errores: {state.errors?.field && <p>{state.errors.field[0]}</p>}
    6. Loading: <button disabled={isPending}>{isPending ? 'Enviando...' : 'Enviar'}</button>

    Flujo:
    • Usuario envía → action → resultado → state se actualiza → re-render
  4. 4

    Step 4: Añadir autenticación y autorización

    Añade comprobaciones de seguridad en el Server Action:

    1. Validación: Zod para todas las entradas
    2. Autenticación: comprueba el session token
    ```typescript
    const cookieStore = await cookies()
    const sessionToken = cookieStore.get('session')?.value
    if (!sessionToken) return { success: false, error: 'Inicia sesión primero' }
    ```
    3. Autorización: comprueba permisos
    ```typescript
    const post = await getPost(postId)
    if (post.authorId !== currentUser.id) {
    return { success: false, error: 'Sin permiso' }
    }
    ```
    4. Ejecuta la operación tras pasar las comprobaciones

    Recuerda: Server Actions no es magia; debes hacer las comprobaciones manualmente
  5. 5

    Step 5: Optimizar la experiencia de usuario

    Añade loading y manejo de errores:

    1. useFormStatus (en el botón):
    ```typescript
    'use client'
    import { useFormStatus } from 'react-dom'
    export function SubmitButton() {
    const { pending } = useFormStatus()
    return <button disabled={pending}>...</button>
    }
    ```
    2. revalidatePath para refrescar caché:
    ```typescript
    import { revalidatePath } from 'next/cache'
    revalidatePath('/posts')
    ```
    3. Actualización optimista (opcional, operaciones muy fiables):
    ```typescript
    const [optimisticState, setOptimisticState] = useOptimistic(initialState)
    ```

    Mejores prácticas:
    • Formulario complejo → useActionState
    • Botón independiente → useFormStatus
    • Tras éxito → refresca caché de páginas relacionadas

FAQ

¿Cuál es la diferencia entre Server Actions y API Routes? ¿Cuándo usar cada uno?
Server Actions encajan en envíos de formularios internos y mutaciones de datos: solo POST, type-safe y menos código. API Routes encajan en APIs REST públicas, peticiones GET e integraciones con terceros. En resumen: interno → Server Actions; externo → API Routes.
¿Son seguras las Server Actions? ¿Qué medidas hay que tomar?
Se ejecutan en el servidor, pero debes aplicar comprobaciones manualmente: 1) validación de entrada (Zod), 2) autenticación (session), 3) autorización (permisos). El framework solo ofrece protección CSRF básica; no confíes en que garantice la seguridad por sí solo.
¿Cuál es la diferencia entre useActionState y useFormStatus?
isPending de useActionState encaja dentro del componente del formulario y te da state y pending a la vez. pending de useFormStatus debe usarse en un hijo del formulario (p. ej. el botón) y solo expone el estado de envío. Formulario complejo → useActionState; botón reutilizable → useFormStatus.
¿Cómo pasar parámetros además de los campos del formulario?
Dos formas: 1) campo oculto <input type="hidden" name="postId" value={postId} />, 2) bind: const actionWithId = action.bind(null, postId) y <form action={actionWithId}>. Recomendado: bind, más limpio.
¿Cómo refrescar datos de la página tras enviar el formulario?
revalidatePath por ruta: revalidatePath('/posts'), o revalidateTag por etiqueta (hay que etiquetar en fetch). Rutas fijas y pocas → revalidatePath; datos repartidos en muchas páginas → revalidateTag.
¿Cuándo usar actualizaciones optimistas?
Encajan en operaciones casi siempre exitosas (me gusta, favoritos) con useOptimistic: actualizas la UI al instante y envías en segundo plano. Si puede fallar a menudo, mejor no: revertir la UI complica más de lo que ayuda.

12 min de lectura · Publicado el: 19 dic 2025 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog