CRUD con Server Actions y Supabase en NextJS

Server Actions son funciones que se ejecutan en el servidor y te permiten modificar datos de forma segura. Combinadas con Supabase, son la forma más limpia de hacer CRUD (Create, Read, Update, Delete) en una app de NextJS. No necesitas API routes ni endpoints manuales -- escribes la lógica y NextJS se encarga del transporte.

Setup: crear el cliente en Server Actions

Dentro de un Server Action, usas el mismo cliente del servidor:

typescript
// actions/tareas.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
 
export async function crearTarea(formData: FormData) {
  const supabase = await createClient()
 
  // El cliente ya tiene el JWT del usuario via cookies
  // Las politicas de RLS se aplican automáticamente
}
Server Actions y cookies

A diferencia de los Server Components, en Server Actions sí puedes escribir cookies. Por eso el setAll del cliente del servidor funciona sin problemas acá.

CREATE: insertar datos

Formulario básico con insert

typescript
// actions/tareas.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
 
export async function crearTarea(formData: FormData) {
  const supabase = await createClient()
 
  const titulo = formData.get('titulo') as string
  const descripcion = formData.get('descripcion') as string
 
  if (!titulo || titulo.trim().length === 0) {
    return { error: 'El título es obligatorio' }
  }
 
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    return { error: 'Debes iniciar sesión' }
  }
 
  const { error } = await supabase
    .from('tareas')
    .insert({
      titulo: titulo.trim(),
      descripcion: descripcion?.trim() || null,
      usuario_id: user.id,
      completada: false,
    })
 
  if (error) {
    return { error: `Error al crear tarea: ${error.message}` }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}
tsx
// app/tareas/nueva/page.tsx
import { crearTarea } from '@/actions/tareas'
import { FormularioTarea } from './formulario-tarea'
 
export default function NuevaTareaPage() {
  return (
    <div>
      <h1>Nueva tarea</h1>
      <FormularioTarea action={crearTarea} />
    </div>
  )
}
tsx
// app/tareas/nueva/formulario-tarea.tsx
'use client'
 
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
 
function BotonSubmit() {
  const { pending } = useFormStatus()
 
  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending ? 'Creando...' : 'Crear tarea'}
    </button>
  )
}
 
interface FormularioTareaProps {
  action: (formData: FormData) => Promise<{ error?: string; success?: boolean }>
}
 
export function FormularioTarea({ action }: FormularioTareaProps) {
  const [state, formAction] = useActionState(action, null)
 
  return (
    <form action={formAction}>
      <div>
        <label htmlFor="titulo">Titulo</label>
        <input
          id="titulo"
          name="titulo"
          type="text"
          required
        />
      </div>
 
      <div>
        <label htmlFor="descripcion">Descripcion (opcional)</label>
        <textarea
          id="descripcion"
          name="descripcion"
          rows={3}
        />
      </div>
 
      {state?.error && (
        <p style={{ color: 'red' }}>{state.error}</p>
      )}
 
      {state?.success && (
        <p style={{ color: 'green' }}>Tarea creada</p>
      )}
 
      <BotonSubmit />
    </form>
  )
}

READ: listar datos

La lectura se hace en Server Components (cubierto en la sección anterior). Acá va un recordatorio rápido:

tsx
// app/tareas/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { ListaTareas } from './lista-tareas'
 
export const dynamic = 'force-dynamic'
 
export default async function TareasPage() {
  const supabase = await createClient()
 
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  const { data: tareas } = await supabase
    .from('tareas')
    .select('id, titulo, descripcion, completada, created_at')
    .order('created_at', { ascending: false })
 
  return (
    <div>
      <h1>Mis tareas</h1>
      <ListaTareas tareas={tareas ?? []} />
    </div>
  )
}

UPDATE: actualizar datos

Marcar tarea como completada

typescript
// actions/tareas.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
 
export async function toggleTarea(id: string, completada: boolean) {
  const supabase = await createClient()
 
  const { error } = await supabase
    .from('tareas')
    .update({ completada: !completada })
    .eq('id', id)
 
  if (error) {
    return { error: `Error al actualizar: ${error.message}` }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}

Editar una tarea completa

typescript
// actions/tareas.ts
export async function editarTarea(id: string, formData: FormData) {
  const supabase = await createClient()
 
  const titulo = formData.get('titulo') as string
  const descripcion = formData.get('descripcion') as string
 
  if (!titulo || titulo.trim().length === 0) {
    return { error: 'El título es obligatorio' }
  }
 
  const { error } = await supabase
    .from('tareas')
    .update({
      titulo: titulo.trim(),
      descripcion: descripcion?.trim() || null,
      updated_at: new Date().toISOString(),
    })
    .eq('id', id)
 
  if (error) {
    return { error: `Error al editar: ${error.message}` }
  }
 
  revalidatePath('/tareas')
  revalidatePath(`/tareas/${id}`)
  return { success: true }
}

Usar update desde un Client Component

tsx
// components/tarea-item.tsx
'use client'
 
import { toggleTarea } from '@/actions/tareas'
import { useTransition } from 'react'
 
interface TareaItemProps {
  id: string
  titulo: string
  completada: boolean
}
 
export function TareaItem({ id, titulo, completada }: TareaItemProps) {
  const [isPending, startTransition] = useTransition()
 
  function handleToggle() {
    startTransition(async () => {
      await toggleTarea(id, completada)
    })
  }
 
  return (
    <li style={{ opacity: isPending ? 0.5 : 1 }}>
      <input
        type="checkbox"
        checked={completada}
        onChange={handleToggle}
        disabled={isPending}
      />
      <span style={{ textDecoration: completada ? 'line-through' : 'none' }}>
        {titulo}
      </span>
    </li>
  )
}

DELETE: eliminar datos

typescript
// actions/tareas.ts
export async function eliminarTarea(id: string) {
  const supabase = await createClient()
 
  const { error } = await supabase
    .from('tareas')
    .delete()
    .eq('id', id)
 
  if (error) {
    return { error: `Error al eliminar: ${error.message}` }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}
tsx
// components/boton-eliminar.tsx
'use client'
 
import { eliminarTarea } from '@/actions/tareas'
import { useTransition } from 'react'
 
export function BotonEliminar({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition()
 
  function handleDelete() {
    if (!confirm('Seguro que queres eliminar esta tarea?')) return
 
    startTransition(async () => {
      await eliminarTarea(id)
    })
  }
 
  return (
    <button
      onClick={handleDelete}
      disabled={isPending}
    >
      {isPending ? 'Eliminando...' : 'Eliminar'}
    </button>
  )
}

revalidatePath: actualizar la UI después de mutaciones

Después de cada mutación (insert, update, delete), necesitas decirle a NextJS que los datos cambiaron para que regenere la página:

typescript
import { revalidatePath } from 'next/cache'
 
// Revalidar una ruta especifica
revalidatePath('/tareas')
 
// Revalidar una ruta dinamica
revalidatePath(`/tareas/${id}`)
 
// Revalidar la raiz (home)
revalidatePath('/')
 
// Revalidar todo el layout de una seccion
revalidatePath('/tareas', 'layout')
Revalida todas las rutas afectadas

Si tu página principal muestra las últimas tareas, revalida tanto /tareas como /. No olvides ninguna ruta que muestre los datos que modificaste.

Manejo de errores

Patrón con retorno de estado

El patrón más robusto es retornar un objeto con el resultado:

typescript
// actions/tareas.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
 
interface ActionResult {
  success: boolean
  error?: string
}
 
export async function crearTarea(
  prevState: ActionResult | null,
  formData: FormData
): Promise<ActionResult> {
  const supabase = await createClient()
 
  // Verificar auth
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) {
    return { success: false, error: 'Debes iniciar sesión' }
  }
 
  // Validar datos
  const titulo = formData.get('titulo') as string
  if (!titulo || titulo.trim().length < 3) {
    return { success: false, error: 'El título debe tener al menos 3 caracteres' }
  }
 
  // Insertar
  const { error } = await supabase
    .from('tareas')
    .insert({
      titulo: titulo.trim(),
      usuario_id: user.id,
    })
 
  if (error) {
    // Errores comunes de Supabase
    if (error.code === '23505') {
      return { success: false, error: 'Ya existe una tarea con ese titulo' }
    }
    if (error.code === '42501') {
      return { success: false, error: 'No tienes permiso para crear tareas' }
    }
    return { success: false, error: 'Error inesperado. Intenta de nuevo.' }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}

Códigos de error comunes de Supabase

CódigoSignificado
23505Violación de constraint UNIQUE
23503Violación de foreign key
23502Violación de NOT NULL
42501Permiso denegado (RLS)
PGRST116La query no devolvió resultados (con .single())

Patrón de optimistic updates

Los optimistic updates (actualizaciones optimistas) mejoran la UX al mostrar el cambio inmediatamente en la UI, antes de que el servidor confirme. Si el servidor falla, se revierte.

tsx
// components/lista-tareas.tsx
'use client'
 
import { toggleTarea, eliminarTarea } from '@/actions/tareas'
import { useOptimistic, useTransition } from 'react'
 
interface Tarea {
  id: string
  titulo: string
  completada: boolean
}
 
export function ListaTareas({ tareas }: { tareas: Tarea[] }) {
  const [isPending, startTransition] = useTransition()
 
  const [tareasOptimistas, actualizarOptimista] = useOptimistic(
    tareas,
    (estado: Tarea[], tareaActualizada: Tarea) =>
      estado.map((t) =>
        t.id === tareaActualizada.id ? tareaActualizada : t
      )
  )
 
  function handleToggle(tarea: Tarea) {
    startTransition(async () => {
      // Actualizar la UI inmediatamente
      actualizarOptimista({
        ...tarea,
        completada: !tarea.completada,
      })
 
      // Enviar al servidor
      await toggleTarea(tarea.id, tarea.completada)
    })
  }
 
  return (
    <ul>
      {tareasOptimistas.map((tarea) => (
        <li key={tarea.id}>
          <input
            type="checkbox"
            checked={tarea.completada}
            onChange={() => handleToggle(tarea)}
          />
          <span style={{
            textDecoration: tarea.completada ? 'line-through' : 'none',
          }}>
            {tarea.titulo}
          </span>
        </li>
      ))}
    </ul>
  )
}

El checkbox se marca (o desmarca) al instante. Si el request al servidor falla, React revierte el estado automáticamente al valor real.

Cuándo usar optimistic updates

Úsalos para acciones que casi nunca fallan: toggle de checkbox, like/unlike, marcar como leído. No los uses para operaciones complejas que pueden tener errores de validación.

Ejemplo completo: app de tareas

Acá está el CRUD completo armado con Server Actions y Supabase.

Schema SQL

sql
CREATE TABLE tareas (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  titulo TEXT NOT NULL,
  descripcion TEXT,
  completada BOOLEAN NOT NULL DEFAULT false,
  usuario_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
ALTER TABLE tareas ENABLE ROW LEVEL SECURITY;
 
-- Los usuarios solo ven sus propias tareas
CREATE POLICY "Usuarios ven sus tareas"
ON tareas FOR SELECT
USING (auth.uid() = usuario_id);
 
-- Los usuarios crean tareas para si mismos
CREATE POLICY "Usuarios crean sus tareas"
ON tareas FOR INSERT
WITH CHECK (auth.uid() = usuario_id);
 
-- Los usuarios editan sus propias tareas
CREATE POLICY "Usuarios editan sus tareas"
ON tareas FOR UPDATE
USING (auth.uid() = usuario_id);
 
-- Los usuarios eliminan sus propias tareas
CREATE POLICY "Usuarios eliminan sus tareas"
ON tareas FOR DELETE
USING (auth.uid() = usuario_id);

Actions

typescript
// actions/tareas.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
 
interface ActionResult {
  success: boolean
  error?: string
}
 
export async function crearTarea(
  prevState: ActionResult | null,
  formData: FormData
): Promise<ActionResult> {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  const titulo = formData.get('titulo') as string
 
  if (!titulo || titulo.trim().length < 1) {
    return { success: false, error: 'El título no puede estar vacío' }
  }
 
  const { error } = await supabase.from('tareas').insert({
    titulo: titulo.trim(),
    descripcion: (formData.get('descripcion') as string)?.trim() || null,
    usuario_id: user.id,
  })
 
  if (error) {
    return { success: false, error: error.message }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}
 
export async function toggleTarea(id: string, completadaActual: boolean) {
  const supabase = await createClient()
 
  await supabase
    .from('tareas')
    .update({ completada: !completadaActual, updated_at: new Date().toISOString() })
    .eq('id', id)
 
  revalidatePath('/tareas')
}
 
export async function editarTarea(
  id: string,
  prevState: ActionResult | null,
  formData: FormData
): Promise<ActionResult> {
  const supabase = await createClient()
 
  const titulo = formData.get('titulo') as string
  if (!titulo || titulo.trim().length < 1) {
    return { success: false, error: 'El título no puede estar vacío' }
  }
 
  const { error } = await supabase
    .from('tareas')
    .update({
      titulo: titulo.trim(),
      descripcion: (formData.get('descripcion') as string)?.trim() || null,
      updated_at: new Date().toISOString(),
    })
    .eq('id', id)
 
  if (error) {
    return { success: false, error: error.message }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}
 
export async function eliminarTarea(id: string) {
  const supabase = await createClient()
 
  const { error } = await supabase
    .from('tareas')
    .delete()
    .eq('id', id)
 
  if (error) {
    return { success: false, error: error.message }
  }
 
  revalidatePath('/tareas')
  return { success: true }
}

Página principal

tsx
// app/tareas/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { ListaTareas } from '@/components/lista-tareas'
import { FormularioNuevaTarea } from '@/components/formulario-nueva-tarea'
 
export const dynamic = 'force-dynamic'
 
export default async function TareasPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  const { data: tareas } = await supabase
    .from('tareas')
    .select('id, titulo, descripcion, completada, created_at')
    .order('created_at', { ascending: false })
 
  const pendientes = tareas?.filter((t) => !t.completada).length ?? 0
  const completadas = tareas?.filter((t) => t.completada).length ?? 0
 
  return (
    <div>
      <h1>Mis tareas</h1>
      <p>{pendientes} pendientes, {completadas} completadas</p>
 
      <FormularioNuevaTarea />
 
      <ListaTareas tareas={tareas ?? []} />
    </div>
  )
}

Cada pieza encaja: el Server Component fetchea los datos, el formulario usa Server Actions para crear, los botones de toggle y eliminar llaman Server Actions directamente. Todo con RLS por debajo para que cada usuario solo toque sus datos.


Resumen

OperaciónMétodo SDKDespués de la mutación
Create.insert({...})revalidatePath('/ruta')
Read.select('*')N/A (Server Component)
Update.update({...}).eq('id', id)revalidatePath('/ruta')
Delete.delete().eq('id', id)revalidatePath('/ruta')
PatrónCuándo usarlo
useActionStateFormularios con feedback de errores
useFormStatusBotón de submit con estado de carga
useTransitionAcciones disparadas por eventos (no formularios)
useOptimisticUI que se actualiza antes de la confirmación del servidor