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:
// 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
// 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 }
}// 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>
)
}// 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:
// 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
// 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
// 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
// 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
// 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 }
}// 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:
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:
// 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ódigo | Significado |
|---|---|
23505 | Violación de constraint UNIQUE |
23503 | Violación de foreign key |
23502 | Violación de NOT NULL |
42501 | Permiso denegado (RLS) |
PGRST116 | La 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.
// 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
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
// 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
// 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ón | Método SDK | Despué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ón | Cuándo usarlo |
|---|---|
useActionState | Formularios con feedback de errores |
useFormStatus | Botón de submit con estado de carga |
useTransition | Acciones disparadas por eventos (no formularios) |
useOptimistic | UI que se actualiza antes de la confirmación del servidor |