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

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ística | Server Actions | API Routes |
|---|---|---|
| Uso | Envío de formularios, mutaciones | API REST, llamadas externas |
| Métodos HTTP | Solo POST | GET/POST/PUT/DELETE, etc. |
| Type-safe | Nativo | Tipos manuales |
| Invocación | Llamada directa a función | fetch |
| Escenario | Lógica interna, formularios | API pública, integraciones |
| Código | Menos | Má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:
'use server': Next.js ejecuta la función en el servidorformData.get(): obtiene valores por el atributonameaction={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:
safeParseno lanza: devuelve{ success: false, error: ... }y puedes manejarlo con calmaflatten().fieldErrors: errores como{ name: ['error1'], email: ['error2'] }, fáciles de mostrar- Respuesta estructurada:
successy 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:
- Usuario envía →
signup - Validación falla →
{ success: false, errors: {...} } useActionStateguarda el resultado enstate- 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:
isPendingde useActionState: dentro del componente del formulariopendingde 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ística | isPending (useActionState) | pending (useFormStatus) |
|---|---|---|
| Dónde | Dentro del formulario | En un hijo del formulario |
| Escenario | Estado global del formulario | Botón reutilizable |
| Flexibilidad | state + pending | solo 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:
- CSRF: solo POST; comprueba que Origin y Host coincidan
- ID de action: cifrado, difícil de enumerar
- 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 pocas →
revalidatePath - Datos en muchas páginas →
revalidateTag
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:
-
Server Actions simplifican formularios, pero no lo son todo. Interno → Server Actions; API externa → Route Handlers. No uses Server Actions para todo.
-
La seguridad es tuya. El framework da lo básico; validación, auth y permisos no son opcionales.
-
Los detalles de UX importan. Loading, errores, optimismo… marcan la diferencia entre «funciona» y «se siente bien». Combina
useActionStateyuseFormStatus.
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
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
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
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
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
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?
¿Son seguras las Server Actions? ¿Qué medidas hay que tomar?
¿Cuál es la diferencia entre useActionState y useFormStatus?
¿Cómo pasar parámetros además de los campos del formulario?
¿Cómo refrescar datos de la página tras enviar el formulario?
¿Cuándo usar actualizaciones optimistas?
12 min de lectura · Publicado el: 19 dic 2025 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Next.js SSR vs SSG vs ISR: guía para elegir la estrategia de renderizado
¿No sabes cuándo usar SSR, SSG o ISR en Next.js? Este artículo compara escenarios reales y un árbol de decisión para elegir la estrategia adecuada y resolver problemas habituales como ISR que no funciona o carga inicial lenta.
Parte 9 de 51
Siguiente
Guía práctica de Next.js Middleware: coincidencia de rutas, limitaciones de Edge Runtime y errores comunes
De un bug en producción a la solución completa: matcher, limitaciones de Edge Runtime y tres escenarios prácticos para evitar las trampas más habituales
Parte 11 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario