nextjs-server-actions

Server Actions en Next.JS

  • 5 min

Hasta ahora, en cualquier aplicación React (y en las versiones antiguas de Next.js), si queríamos que un usuario enviara un formulario (por ejemplo, para registrarse o crear un post), teníamos que construir una infraestructura considerable:

  1. Crear un endpoint de API en el backend (ej: POST /api/create-user).
  2. En el frontend, crear un estado para cada input (useState).
  3. Manejar el evento onSubmit.
  4. Hacer un fetch('/api/create-user') con el cuerpo JSON.
  5. Gestionar la respuesta y los errores.

En el Next.js, buena parte de este boilerplate desaparece gracias a las Server Actions.

Las Server Actions nos permiten definir funciones asíncronas que se ejecutan en el servidor, pero que pueden ser invocadas directamente desde nuestros componentes (incluso desde el cliente), como si fueran funciones locales de JavaScript.

¿Qué es una Server Action?

Técnicamente, es una función marcada con la directiva 'use server'.

Cuando Next.js ve esta directiva, crea automáticamente un endpoint HTTP interno, serializa los argumentos que le pasas a la función y gestiona la llamada de red por ti. Tú solo ves una llamada a función; Next.js ve una petición POST.

Creando nuestra primera Acción

Vamos a crear un formulario simple para añadir una tarea (una App de Todo, el hola mundo de las SPA).

Lo ideal por organización es tener las acciones en un archivo separado, por ejemplo src/app/actions.ts.

// src/app/actions.ts
'use server' // 👈 Esto marca todas las exportaciones como Server Actions

import db from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createTodo(formData: FormData) {
  // 1. Extraemos los datos del formulario nativo
  const title = formData.get('title');

  if (!title || typeof title !== 'string') return;

  // 2. Operación de Base de Datos (Segura, estamos en el servidor)
  await db.todo.create({ data: { title } });

  // 3. ¡IMPORTANTE! Avisamos a Next.js para que actualice la vista
  revalidatePath('/todos');
}

Copied!

Fijaos en el uso de FormData. Next.js fomenta el uso de los estándares web. No necesitamos crear un estado React para cada input; el formulario HTML nativo ya sabe recopilar sus datos.

Conectando el formulario

Ahora vamos a nuestro componente (que podría ser un Server Component) y usamos la acción.

// src/app/todos/page.tsx
import { createTodo } from '../actions';

export default async function Page() {
  const todos = await db.todo.findMany();

  return (
    <main>
      <h1>Mis Tareas</h1>
      
      {/* Listado de tareas */}
      <ul>
        {todos.map(t => <li key={t.id}>{t.title}</li>)}
      </ul>

      {/* Formulario de creación */}
      {/* Pasamos la función DIRECTAMENTE al prop action */}
      <form action={createTodo} className="flex gap-2">
        <input 
          name="title" 
          type="text" 
          placeholder="Nueva tarea..." 
          className="border p-2"
        />
        <button type="submit" className="bg-blue-500 text-white p-2">
          Añadir
        </button>
      </form>
    </main>
  );
}

Copied!

Queda bastante limpio, ¿verdad? No hay useState, no hay onSubmit, no hay fetch. El formulario funciona incluso si el usuario tiene JavaScript deshabilitado en su navegador (Progressive Enhancement).

El concepto de revalidatePath

En el ejemplo anterior he usado revalidatePath('/todos'). Conviene entenderlo bien.

En una SPA tradicional (Vite), cuando creábamos un item, teníamos que actualizar manualmente el array local de tareas (setTodos([...todos, newTodo])) para que el usuario viera el cambio instantáneamente.

En Next.js Server Components, la lista de tareas es estática (HTML generado en servidor). Si insertamos algo en la BBDD, el HTML que el usuario está viendo ya es “viejo”.

Al llamar a revalidatePath:

Next.js purga la caché de esa ruta específica.

Vuelve a ejecutar el Server Component Page en el servidor.

Envía el nuevo HTML actualizado al navegador.

El navegador actualiza la lista sin recargar la página completa.

Todo esto ocurre en milisegundos y en una sola ida y vuelta (Round Trip).

Feedback Visual: useFormStatus

El único problema del ejemplo anterior es que el usuario no sabe si se está enviando la tarea. Como no tenemos un estado isLoading, ¿cómo mostramos un spinner o deshabilitamos el botón?

Para esto necesitamos un Client Component (porque necesitamos interactividad) y un hook especial de React DOM llamado useFormStatus.

Vamos a extraer el botón a su propio componente:

// src/components/SubmitButton.tsx
'use client' // Necesario para usar hooks

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  // Este hook nos dice si el formulario padre se está enviando
  const { pending } = useFormStatus();

  return (
    <button 
      type="submit" 
      disabled={pending}
      className="bg-blue-500 disabled:bg-gray-400 text-white p-2"
    >
      {pending ? 'Guardando...' : 'Añadir'}
    </button>
  );
}

Copied!

Y actualizamos nuestra página:

import { SubmitButton } from '@/components/SubmitButton';

// ... dentro del form ...
<form action={createTodo}>
  <input name="title" ... />
  {/* Usamos el componente cliente aquí */}
  <SubmitButton /> 
</form>

Copied!

useFormStatus solo funciona si el componente que lo usa está renderizado dentro del elemento <form>. No funcionaría si lo usáramos en el propio componente Page que contiene el form.

Invocación desde Client Components

Aunque el ejemplo del formulario es el más común, también podéis llamar a Server Actions desde un onClick en un Client Component.

'use client'
import { deleteTodo } from '../actions';

export function DeleteButton({ id }: { id: string }) {
  return (
    <button 
      onClick={async () => {
        // Podemos llamar a la Server Action como una función normal
        await deleteTodo(id);
        alert('Borrado!');
      }}
    >
      Borrar
    </button>
  );
}

Copied!

Next.js se encarga de hacer el puente entre ese clic en el navegador y la función en vuestro servidor.