Supabase con NextJS: Guia Completa desde Cero
Aprende a integrar Supabase con NextJS paso a paso. CRUD, autenticacion, real-time, Row Level Security y mejores practicas con codigo listo para usar.
Supabase con NextJS: Guia Completa desde Cero
Esta guia de Supabase con NextJS cubre todo lo que necesitas para construir una aplicacion fullstack: desde la configuracion inicial hasta autenticacion, CRUD, real-time y Row Level Security. Si ya tienes experiencia con NextJS y quieres agregar un backend sin montar un servidor, Supabase es probablemente la opcion mas directa que vas a encontrar.
Vamos a trabajar con el App Router de NextJS, TypeScript, y las librerias oficiales de Supabase. Todo el codigo es funcional y lo puedes adaptar a tu proyecto.
Que es Supabase?
Supabase es una plataforma open source que te da:
- Base de datos PostgreSQL completa (no una base NoSQL limitada)
- Autenticacion con email, OAuth, magic links
- Storage para archivos
- Funciones Edge (serverless)
- Real-time por WebSockets
- API REST automatica generada desde tu schema de PostgreSQL
La diferencia clave con Firebase: Supabase usa PostgreSQL. Eso significa SQL real, relaciones, joins, constraints, y todo lo que ya conoces si has trabajado con bases de datos relacionales.
Por que Supabase + NextJS?
NextJS con App Router tiene Server Components y Client Components. Supabase se adapta a ambos mundos:
- Server Components: queries directas a la base de datos sin exponer nada al navegador
- Client Components: subscripciones en tiempo real y operaciones interactivas
- Middleware: verificacion de sesiones antes de renderizar paginas
- Server Actions: mutaciones seguras desde formularios
Si quieres entender a fondo la diferencia entre Server y Client Components, tengo una guia dedicada al tema.
Crear un proyecto en Supabase
Paso 1: Crear cuenta y proyecto
Ve a supabase.com y crea una cuenta. Despues, crea un nuevo proyecto:
- Click en New Project
- Elige una organizacion (o crea una)
- Nombre del proyecto: el que quieras (ejemplo:
mi-app) - Password de la base de datos: genera una segura y guardala
- Region: elige la mas cercana a tus usuarios (para LATAM, East US funciona bien)
- Click en Create new project
Supabase tarda unos 2 minutos en provisionar tu base de datos.
Paso 2: Obtener las credenciales
Una vez que el proyecto este listo, ve a Settings > API y copia:
- Project URL: algo como
https://abcdefghij.supabase.co - anon (public) key: una llave JWT larga
Sobre la anon key
La anon key es publica por diseno. Esta pensada para usarse en el navegador. La seguridad no depende de esconder esta llave, sino de las politicas de Row Level Security (RLS) que configures en tu base de datos. Mas adelante en esta guia vamos a configurar RLS.
Configurar el proyecto de NextJS
Si todavia no tienes un proyecto de NextJS, crealo:
npx create-next-app@latest mi-app --typescript --tailwind --eslint --app --src-dir
cd mi-appInstalar dependencias de Supabase
Necesitas dos paquetes:
npm install @supabase/supabase-js @supabase/ssr@supabase/supabase-js: el cliente principal de Supabase@supabase/ssr: adaptadores para frameworks server-side como NextJS (manejo de cookies, sesiones)
Configurar variables de entorno
Crea un archivo .env.local en la raiz de tu proyecto:
NEXT_PUBLIC_SUPABASE_URL=tu_project_url_aqui
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu_anon_key_aquiVariables de entorno en NextJS
Las variables con prefijo NEXT_PUBLIC_ son accesibles tanto en el servidor como en el navegador. Las que no tienen el prefijo solo estan disponibles en el servidor. Para Supabase, ambas necesitan el prefijo porque el cliente del navegador tambien las usa. Si quieres profundizar en como funcionan las variables de entorno en NextJS y Vercel, revisa la guia de variables de entorno.
Asegurate de que .env.local este en tu .gitignore (lo esta por default en proyectos de NextJS). Nunca subas tus API keys a un repositorio publico. Si trabajas en un proyecto con multiples desarrolladores, herramientas como datahogo pueden escanear tu repo automaticamente y detectar secrets expuestos antes de que lleguen a produccion.
Crear el cliente Supabase para Server Components
Esta es la parte que genera mas confusion. Necesitas dos clientes de Supabase diferentes: uno para el servidor y otro para el navegador. Empecemos por el servidor.
Crea la siguiente estructura:
src/
lib/
supabase/
server.ts
client.ts
middleware.tsCliente para el servidor
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// setAll puede fallar en Server Components (solo lectura)
// Esto esta bien: el middleware se encarga de refrescar las cookies
}
},
},
}
)
}Este cliente lo usas en:
- Server Components (
page.tsx,layout.tsx) - Server Actions
- Route Handlers (
route.ts)
Por que el try/catch en setAll?
En Server Components, las cookies son de solo lectura. No puedes modificarlas. El try/catch evita que tu app truene cuando Supabase intenta refrescar un token. El middleware (que veremos despues) se encarga de hacer el refresh correctamente.
Como usar el cliente en un Server Component
// src/app/notas/page.tsx
import { createClient } from "@/lib/supabase/server"
export default async function NotasPage() {
const supabase = await createClient()
const { data: notas, error } = await supabase
.from("notas")
.select("*")
.order("created_at", { ascending: false })
if (error) {
console.error("Error al obtener notas:", error.message)
return <p>Error al cargar las notas.</p>
}
return (
<div>
<h1>Mis Notas</h1>
<ul>
{notas.map((nota) => (
<li key={nota.id}>{nota.titulo}</li>
))}
</ul>
</div>
)
}Esto se ejecuta completamente en el servidor. El navegador recibe HTML puro, sin JavaScript de Supabase.
Crear el cliente Supabase para Client Components
Para operaciones interactivas (formularios, real-time, etc.), necesitas un cliente que funcione en el navegador.
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr"
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}Como usar el cliente en un Client Component
"use client"
import { createClient } from "@/lib/supabase/client"
import { useEffect, useState } from "react"
interface Nota {
id: string
titulo: string
contenido: string
created_at: string
}
export function ListaNotasRealtime() {
const [notas, setNotas] = useState<Nota[]>([])
const supabase = createClient()
useEffect(() => {
async function cargarNotas() {
const { data } = await supabase
.from("notas")
.select("*")
.order("created_at", { ascending: false })
if (data) setNotas(data)
}
cargarNotas()
}, [])
return (
<ul>
{notas.map((nota) => (
<li key={nota.id}>{nota.titulo}</li>
))}
</ul>
)
}Si vienes del mundo de async/await, el patron es el mismo: las funciones de Supabase retornan Promises que resuelves con await. La diferencia es que en un useEffect necesitas crear una funcion async interna porque el callback de useEffect no puede ser async directamente.
CRUD basico con Supabase
Antes de escribir codigo, necesitas una tabla. Vamos a crear una tabla de notas en Supabase.
Crear la tabla desde el Dashboard
Ve al SQL Editor en tu dashboard de Supabase y ejecuta:
CREATE TABLE notas (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
titulo TEXT NOT NULL,
contenido TEXT,
usuario_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Habilitar Row Level Security (lo configuramos mas adelante)
ALTER TABLE notas ENABLE ROW LEVEL SECURITY;Supabase genera automaticamente una API REST a partir de tu schema. Cada tabla que creas se vuelve un endpoint accesible a traves del cliente. No necesitas crear rutas de API manualmente.
Tipos de TypeScript
Para tener autocompletado y type safety, genera los tipos de tu base de datos:
npx supabase gen types typescript --project-id tu_project_id > src/types/database.tsEsto genera un archivo con todos los tipos de tus tablas. Despues puedes usarlo asi:
// src/types/database.ts (generado automaticamente, fragmento)
export interface Database {
public: {
Tables: {
notas: {
Row: {
id: string
titulo: string
contenido: string | null
usuario_id: string | null
created_at: string
updated_at: string
}
Insert: {
id?: string
titulo: string
contenido?: string | null
usuario_id?: string | null
created_at?: string
updated_at?: string
}
Update: {
id?: string
titulo?: string
contenido?: string | null
usuario_id?: string | null
created_at?: string
updated_at?: string
}
}
}
}
}Y al crear el cliente, pasas el tipo:
// src/lib/supabase/server.ts (actualizado con tipos)
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
import type { Database } from "@/types/database"
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Components: cookies de solo lectura
}
},
},
}
)
}Ahora supabase.from("notas") te da autocompletado de las columnas.
Insertar datos (CREATE)
Con Server Actions:
// src/app/notas/actions.ts
"use server"
import { createClient } from "@/lib/supabase/server"
import { revalidatePath } from "next/cache"
export async function crearNota(formData: FormData) {
const supabase = await createClient()
const titulo = formData.get("titulo") as string
const contenido = formData.get("contenido") as string
if (!titulo || titulo.trim() === "") {
return { error: "El titulo es requerido" }
}
const { data, error } = await supabase
.from("notas")
.insert({
titulo: titulo.trim(),
contenido: contenido?.trim() || null,
})
.select()
.single()
if (error) {
return { error: error.message }
}
revalidatePath("/notas")
return { data }
}Y el formulario:
// src/app/notas/nueva/page.tsx
import { crearNota } from "../actions"
export default function NuevaNotaPage() {
return (
<form action={crearNota}>
<div>
<label htmlFor="titulo">Titulo</label>
<input
id="titulo"
name="titulo"
type="text"
required
className="border rounded px-3 py-2 w-full"
/>
</div>
<div>
<label htmlFor="contenido">Contenido</label>
<textarea
id="contenido"
name="contenido"
rows={5}
className="border rounded px-3 py-2 w-full"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Crear Nota
</button>
</form>
)
}Leer datos (READ)
Ya vimos el ejemplo basico con select("*"). Aqui hay variaciones utiles:
// Obtener todas las notas
const { data: notas } = await supabase
.from("notas")
.select("*")
// Obtener solo ciertos campos
const { data: notas } = await supabase
.from("notas")
.select("id, titulo, created_at")
// Obtener una nota por ID
const { data: nota } = await supabase
.from("notas")
.select("*")
.eq("id", notaId)
.single()
// Obtener notas con datos de usuario (join)
const { data: notas } = await supabase
.from("notas")
.select(`
id,
titulo,
contenido,
created_at,
usuario:auth.users(email)
`)Actualizar datos (UPDATE)
// src/app/notas/actions.ts (agregar a las actions existentes)
export async function actualizarNota(formData: FormData) {
const supabase = await createClient()
const id = formData.get("id") as string
const titulo = formData.get("titulo") as string
const contenido = formData.get("contenido") as string
const { data, error } = await supabase
.from("notas")
.update({
titulo: titulo.trim(),
contenido: contenido?.trim() || null,
updated_at: new Date().toISOString(),
})
.eq("id", id)
.select()
.single()
if (error) {
return { error: error.message }
}
revalidatePath("/notas")
return { data }
}Eliminar datos (DELETE)
export async function eliminarNota(formData: FormData) {
const supabase = await createClient()
const id = formData.get("id") as string
const { error } = await supabase
.from("notas")
.delete()
.eq("id", id)
if (error) {
return { error: error.message }
}
revalidatePath("/notas")
return { success: true }
}Un boton de eliminar en tu componente:
// src/components/BotonEliminar.tsx
import { eliminarNota } from "@/app/notas/actions"
export function BotonEliminar({ notaId }: { notaId: string }) {
return (
<form action={eliminarNota}>
<input type="hidden" name="id" value={notaId} />
<button
type="submit"
className="text-red-500 hover:text-red-700"
>
Eliminar
</button>
</form>
)
}Queries avanzadas
El query builder de Supabase es bastante potente. Aqui van los patrones mas comunes.
Filtros
// Igualdad
const { data } = await supabase
.from("notas")
.select("*")
.eq("usuario_id", userId)
// No igual
const { data } = await supabase
.from("notas")
.select("*")
.neq("titulo", "Borrador")
// Mayor que / Menor que
const { data } = await supabase
.from("notas")
.select("*")
.gt("created_at", "2026-01-01")
.lt("created_at", "2026-12-31")
// Contiene texto (LIKE)
const { data } = await supabase
.from("notas")
.select("*")
.ilike("titulo", "%javascript%")
// Multiples valores (IN)
const { data } = await supabase
.from("notas")
.select("*")
.in("id", [id1, id2, id3])
// Filtro con OR
const { data } = await supabase
.from("notas")
.select("*")
.or("titulo.ilike.%react%,titulo.ilike.%nextjs%")
// NULL check
const { data } = await supabase
.from("notas")
.select("*")
.is("contenido", null)Ordenamiento
// Ordenar por fecha descendente
const { data } = await supabase
.from("notas")
.select("*")
.order("created_at", { ascending: false })
// Ordenamiento multiple
const { data } = await supabase
.from("notas")
.select("*")
.order("usuario_id", { ascending: true })
.order("created_at", { ascending: false })Paginacion
Supabase soporta paginacion con range():
// Pagina 1 (primeros 10 resultados)
const { data, count } = await supabase
.from("notas")
.select("*", { count: "exact" })
.range(0, 9)
.order("created_at", { ascending: false })
// Pagina 2 (resultados 11-20)
const { data } = await supabase
.from("notas")
.select("*", { count: "exact" })
.range(10, 19)
.order("created_at", { ascending: false })Un helper de paginacion reutilizable:
// src/lib/supabase/helpers.ts
interface PaginacionParams {
pagina: number
porPagina: number
}
export function calcularRango({ pagina, porPagina }: PaginacionParams) {
const desde = (pagina - 1) * porPagina
const hasta = desde + porPagina - 1
return { desde, hasta }
}
// Uso
const { desde, hasta } = calcularRango({ pagina: 3, porPagina: 10 })
const { data, count } = await supabase
.from("notas")
.select("*", { count: "exact" })
.range(desde, hasta)
.order("created_at", { ascending: false })Componente de paginacion en Server Component
// src/app/notas/page.tsx
import { createClient } from "@/lib/supabase/server"
import { calcularRango } from "@/lib/supabase/helpers"
import Link from "next/link"
const POR_PAGINA = 10
interface Props {
searchParams: Promise<{ pagina?: string }>
}
export default async function NotasPage({ searchParams }: Props) {
const params = await searchParams
const paginaActual = Number(params.pagina) || 1
const supabase = await createClient()
const { desde, hasta } = calcularRango({
pagina: paginaActual,
porPagina: POR_PAGINA,
})
const { data: notas, count } = await supabase
.from("notas")
.select("*", { count: "exact" })
.range(desde, hasta)
.order("created_at", { ascending: false })
const totalPaginas = Math.ceil((count || 0) / POR_PAGINA)
return (
<div>
<h1>Mis Notas</h1>
<ul>
{notas?.map((nota) => (
<li key={nota.id}>{nota.titulo}</li>
))}
</ul>
<nav className="flex gap-2 mt-4">
{paginaActual > 1 && (
<Link href={`/notas?pagina=${paginaActual - 1}`}>
Anterior
</Link>
)}
{paginaActual < totalPaginas && (
<Link href={`/notas?pagina=${paginaActual + 1}`}>
Siguiente
</Link>
)}
</nav>
</div>
)
}Subscripciones en tiempo real
Una de las funcionalidades mas interesantes de Supabase es el real-time. Puedes escuchar cambios en tu base de datos a traves de WebSockets.
Real-time solo en Client Components
Las subscripciones de real-time necesitan una conexion WebSocket persistente, algo que solo funciona en el navegador. Siempre usa "use client" para componentes con subscripciones.
Habilitar real-time en tu tabla
Primero, activa real-time para la tabla notas en el dashboard de Supabase:
- Ve a Database > Replication
- Habilita la tabla
notaspara real-time
O por SQL:
ALTER PUBLICATION supabase_realtime ADD TABLE notas;Componente con subscripcion
"use client"
import { createClient } from "@/lib/supabase/client"
import { useEffect, useState } from "react"
import type { Database } from "@/types/database"
type Nota = Database["public"]["Tables"]["notas"]["Row"]
export function NotasRealtime({ notasIniciales }: { notasIniciales: Nota[] }) {
const [notas, setNotas] = useState<Nota[]>(notasIniciales)
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel("notas-cambios")
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "notas",
},
(payload) => {
const nuevaNota = payload.new as Nota
setNotas((prev) => [nuevaNota, ...prev])
}
)
.on(
"postgres_changes",
{
event: "DELETE",
schema: "public",
table: "notas",
},
(payload) => {
const notaEliminada = payload.old as Nota
setNotas((prev) => prev.filter((n) => n.id !== notaEliminada.id))
}
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "notas",
},
(payload) => {
const notaActualizada = payload.new as Nota
setNotas((prev) =>
prev.map((n) => (n.id === notaActualizada.id ? notaActualizada : n))
)
}
)
.subscribe()
// Limpiar la subscripcion cuando el componente se desmonte
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return (
<ul>
{notas.map((nota) => (
<li key={nota.id}>
<strong>{nota.titulo}</strong>
<p>{nota.contenido}</p>
</li>
))}
</ul>
)
}Combinar Server Component con Client Component
El patron recomendado es cargar los datos iniciales en el Server Component y pasar al Client Component para actualizaciones en tiempo real:
// src/app/notas/page.tsx
import { createClient } from "@/lib/supabase/server"
import { NotasRealtime } from "@/components/NotasRealtime"
export default async function NotasPage() {
const supabase = await createClient()
const { data: notas } = await supabase
.from("notas")
.select("*")
.order("created_at", { ascending: false })
return (
<div>
<h1>Notas en Tiempo Real</h1>
<NotasRealtime notasIniciales={notas || []} />
</div>
)
}Esto te da lo mejor de ambos mundos: carga inicial rapida desde el servidor (bueno para SEO), y actualizaciones en vivo en el cliente.
Autenticacion con Supabase Auth
Supabase tiene un sistema de autenticacion completo. Vamos a implementar el flujo mas comun: registro e inicio de sesion con email y password.
Server Actions para auth
// src/app/auth/actions.ts
"use server"
import { createClient } from "@/lib/supabase/server"
import { redirect } from "next/navigation"
export async function registrar(formData: FormData) {
const supabase = await createClient()
const email = formData.get("email") as string
const password = formData.get("password") as string
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
return { error: error.message }
}
// Supabase envia un email de confirmacion por default
redirect("/auth/verificar-email")
}
export async function iniciarSesion(formData: FormData) {
const supabase = await createClient()
const email = formData.get("email") as string
const password = formData.get("password") as string
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
return { error: error.message }
}
redirect("/notas")
}
export async function cerrarSesion() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect("/auth/login")
}Formulario de login
// src/app/auth/login/page.tsx
import { iniciarSesion } from "../actions"
export default function LoginPage() {
return (
<div className="max-w-md mx-auto mt-10">
<h1 className="text-2xl font-bold mb-6">Iniciar Sesion</h1>
<form action={iniciarSesion} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 border rounded px-3 py-2 w-full"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={6}
className="mt-1 border rounded px-3 py-2 w-full"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
>
Iniciar Sesion
</button>
</form>
<p className="mt-4 text-sm text-gray-600">
No tienes cuenta?{" "}
<a href="/auth/registro" className="text-blue-600 hover:underline">
Registrate aqui
</a>
</p>
</div>
)
}Formulario de registro
// src/app/auth/registro/page.tsx
import { registrar } from "../actions"
export default function RegistroPage() {
return (
<div className="max-w-md mx-auto mt-10">
<h1 className="text-2xl font-bold mb-6">Crear Cuenta</h1>
<form action={registrar} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 border rounded px-3 py-2 w-full"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={6}
className="mt-1 border rounded px-3 py-2 w-full"
/>
</div>
<button
type="submit"
className="w-full bg-green-600 text-white py-2 rounded hover:bg-green-700"
>
Crear Cuenta
</button>
</form>
</div>
)
}Obtener el usuario actual
En un Server Component:
// src/app/perfil/page.tsx
import { createClient } from "@/lib/supabase/server"
import { redirect } from "next/navigation"
export default async function PerfilPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect("/auth/login")
}
return (
<div>
<h1>Mi Perfil</h1>
<p>Email: {user.email}</p>
<p>ID: {user.id}</p>
<p>Creado: {new Date(user.created_at).toLocaleDateString("es-MX")}</p>
</div>
)
}getUser vs getSession
Siempre usa getUser() para verificar la autenticacion en el servidor. getSession() lee datos del JWT local que podria estar manipulado. getUser() hace una llamada a Supabase para validar que el usuario realmente existe y su sesion es valida.
Proteger rutas con middleware
El middleware de NextJS se ejecuta antes de que cualquier pagina se renderice. Es el lugar ideal para verificar autenticacion.
// src/middleware.ts
import { createServerClient } from "@supabase/ssr"
import { NextResponse, type NextRequest } from "next/server"
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// No modificar esta linea: refresca la sesion del usuario
const {
data: { user },
} = await supabase.auth.getUser()
// Rutas que requieren autenticacion
const rutasProtegidas = ["/notas", "/perfil"]
const esRutaProtegida = rutasProtegidas.some((ruta) =>
request.nextUrl.pathname.startsWith(ruta)
)
if (esRutaProtegida && !user) {
const url = request.nextUrl.clone()
url.pathname = "/auth/login"
return NextResponse.redirect(url)
}
// Si el usuario ya esta autenticado, no mostrar login/registro
const rutasAuth = ["/auth/login", "/auth/registro"]
const esRutaAuth = rutasAuth.some((ruta) =>
request.nextUrl.pathname.startsWith(ruta)
)
if (esRutaAuth && user) {
const url = request.nextUrl.clone()
url.pathname = "/notas"
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
// Excluir archivos estaticos y assets
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
}Como funciona este middleware
Cada request pasa por este middleware. Primero, crea un cliente Supabase que puede leer y escribir cookies (para refrescar tokens expirados). Despues, verifica si el usuario esta autenticado. Si intenta acceder a una ruta protegida sin sesion, lo redirige al login. Si ya tiene sesion y va al login, lo manda a la app.
Flujo completo de autenticacion
Para que todo funcione correctamente, necesitas un route handler para el callback de confirmacion de email:
// src/app/auth/callback/route.ts
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get("code")
const next = searchParams.get("next") ?? "/notas"
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
// Si algo falla, redirigir al login con error
return NextResponse.redirect(`${origin}/auth/login?error=auth_callback_failed`)
}Row Level Security (RLS)
Esto es critico. Sin RLS, cualquier persona con tu anon key puede leer y modificar toda tu base de datos. RLS es lo que convierte a Supabase de "cualquiera puede hacer lo que quiera" a "cada usuario solo puede tocar sus datos".
Concepto basico
RLS funciona con politicas (policies). Cada politica define:
- Quien puede hacer la accion (autenticado, anonimo, un rol especifico)
- Que accion (SELECT, INSERT, UPDATE, DELETE)
- Que filas (una condicion SQL)
Politicas para la tabla notas
-- Politica: los usuarios solo pueden ver sus propias notas
CREATE POLICY "Usuarios ven sus notas"
ON notas
FOR SELECT
USING (auth.uid() = usuario_id);
-- Politica: los usuarios solo pueden crear notas para si mismos
CREATE POLICY "Usuarios crean sus notas"
ON notas
FOR INSERT
WITH CHECK (auth.uid() = usuario_id);
-- Politica: los usuarios solo pueden actualizar sus propias notas
CREATE POLICY "Usuarios actualizan sus notas"
ON notas
FOR UPDATE
USING (auth.uid() = usuario_id)
WITH CHECK (auth.uid() = usuario_id);
-- Politica: los usuarios solo pueden eliminar sus propias notas
CREATE POLICY "Usuarios eliminan sus notas"
ON notas
FOR DELETE
USING (auth.uid() = usuario_id);Que es auth.uid()?
auth.uid() es una funcion de Supabase que retorna el UUID del usuario autenticado que esta haciendo la peticion. Supabase extrae esta informacion del JWT que el cliente envia automaticamente. Si no hay usuario autenticado, auth.uid() retorna null.
Verificar que RLS esta funcionando
Una prueba rapida para confirmar que las politicas estan activas:
-- Verificar que RLS esta habilitado en la tabla
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'notas';
-- Ver las politicas existentes
SELECT policyname, cmd, qual
FROM pg_policies
WHERE tablename = 'notas';Politica para contenido publico
A veces necesitas que ciertos datos sean publicos (por ejemplo, un blog donde cualquiera puede leer los posts):
-- Cualquiera puede leer notas marcadas como publicas
CREATE POLICY "Notas publicas visibles para todos"
ON notas
FOR SELECT
USING (es_publica = true);
-- Pero solo el dueno puede modificarlas
CREATE POLICY "Solo el dueno modifica"
ON notas
FOR UPDATE
USING (auth.uid() = usuario_id);Politica con roles
Si tienes un sistema con roles (admin, editor, usuario):
-- Admins pueden ver todo
CREATE POLICY "Admins ven todo"
ON notas
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM perfiles
WHERE perfiles.id = auth.uid()
AND perfiles.rol = 'admin'
)
);No confies solo en el frontend
Nunca filtres datos solo en tu codigo de NextJS. Si un usuario sabe la URL de tu API de Supabase (que es publica), puede hacer peticiones directas. RLS es la unica barrera real entre tus datos y el mundo exterior. Configuralo siempre.
Insertar con usuario_id automatico
En vez de confiar en que el frontend envie el usuario_id correcto, puedes hacer que la base de datos lo asigne automaticamente:
-- Asignar usuario_id automaticamente al insertar
ALTER TABLE notas
ALTER COLUMN usuario_id SET DEFAULT auth.uid();Ahora la politica de INSERT solo necesita verificar que el usuario_id coincida:
CREATE POLICY "Usuarios crean sus notas"
ON notas
FOR INSERT
WITH CHECK (auth.uid() = usuario_id);Y en tu Server Action ya no necesitas enviar el usuario_id:
export async function crearNota(formData: FormData) {
const supabase = await createClient()
const titulo = formData.get("titulo") as string
const contenido = formData.get("contenido") as string
// No necesitas enviar usuario_id, la base de datos lo asigna
const { data, error } = await supabase
.from("notas")
.insert({
titulo: titulo.trim(),
contenido: contenido?.trim() || null,
})
.select()
.single()
if (error) {
return { error: error.message }
}
revalidatePath("/notas")
return { data }
}Validar datos antes de enviar a Supabase
Supabase valida tipos a nivel de base de datos, pero es buena practica validar en tu codigo tambien. Si usas Zod para validar, puedes crear schemas que validen los datos antes de llegar a Supabase:
// src/lib/schemas/nota.ts
import { z } from "zod"
export const notaSchema = z.object({
titulo: z
.string()
.min(1, "El titulo es requerido")
.max(200, "El titulo es muy largo"),
contenido: z
.string()
.max(10000, "El contenido es muy largo")
.nullable()
.optional(),
})
export type NotaInput = z.infer<typeof notaSchema>// En tu Server Action
export async function crearNota(formData: FormData) {
const supabase = await createClient()
const datos = {
titulo: formData.get("titulo") as string,
contenido: formData.get("contenido") as string | null,
}
// Validar con Zod antes de enviar a Supabase
const resultado = notaSchema.safeParse(datos)
if (!resultado.success) {
return { error: resultado.error.flatten().fieldErrors }
}
const { data, error } = await supabase
.from("notas")
.insert(resultado.data)
.select()
.single()
if (error) {
return { error: error.message }
}
revalidatePath("/notas")
return { data }
}Mejores practicas
Despues de trabajar con Supabase en produccion, estas son las cosas que te van a ahorrar problemas.
1. Siempre habilita RLS
No hay excepcion. Incluso en tablas que crees que no necesitan proteccion. Si una tabla tiene RLS deshabilitado, cualquier persona con tu anon key puede leer y escribir en ella.
-- Hazlo inmediatamente despues de crear cada tabla
ALTER TABLE mi_tabla ENABLE ROW LEVEL SECURITY;2. Usa los tipos generados
Regenera los tipos cada vez que cambies el schema:
npx supabase gen types typescript --project-id tu_project_id > src/types/database.tsAgregalo como script en tu package.json:
{
"scripts": {
"db:types": "supabase gen types typescript --project-id tu_project_id > src/types/database.ts"
}
}3. Maneja errores de forma consistente
Crea un helper para los errores de Supabase:
// src/lib/supabase/errors.ts
import { PostgrestError } from "@supabase/supabase-js"
export function manejarErrorSupabase(error: PostgrestError | null): string | null {
if (!error) return null
// Errores comunes de PostgreSQL
const errores: Record<string, string> = {
"23505": "Ya existe un registro con esos datos",
"23503": "No se puede eliminar porque otros registros dependen de este",
"42501": "No tienes permisos para realizar esta accion",
"23502": "Faltan campos requeridos",
}
return errores[error.code] || error.message
}4. No hagas queries desde Client Components si puedes evitarlo
El patron recomendado:
// MAL: query en Client Component
"use client"
export function MiComponente() {
useEffect(() => {
supabase.from("notas").select("*") // Esto se ejecuta en el navegador
}, [])
}
// BIEN: query en Server Component, pasar datos como props
// Server Component (page.tsx)
const { data } = await supabase.from("notas").select("*")
return <MiComponenteCliente notas={data} />La excepcion es cuando necesitas real-time o interacciones del usuario que requieren queries dinamicas.
5. Usa variables de entorno por ambiente
En tu proyecto de Vercel, configura variables de entorno diferentes para preview y produccion. Cada ambiente de Supabase (development, staging, production) debe tener su propio proyecto con sus propias claves.
6. Indexa las columnas que filtras frecuentemente
Si haces queries frecuentes por usuario_id y created_at, crea indices:
CREATE INDEX idx_notas_usuario_id ON notas(usuario_id);
CREATE INDEX idx_notas_created_at ON notas(created_at DESC);
CREATE INDEX idx_notas_usuario_fecha ON notas(usuario_id, created_at DESC);7. No expongas la service_role key
Supabase te da dos llaves:
- anon key: publica, limitada por RLS. Segura para el navegador.
- service_role key: bypass de RLS. Nunca la expongas al cliente.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://tu-proyecto.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu_anon_key # Publica, ok
SUPABASE_SERVICE_ROLE_KEY=tu_service_role_key # Sin NEXT_PUBLIC_, solo servidorLa service_role key solo se debe usar en Server Actions, Route Handlers o scripts de migracion que corran en el servidor.
Estructura final del proyecto
Despues de seguir toda la guia, tu proyecto deberia verse asi:
src/
app/
auth/
callback/
route.ts # Callback de confirmacion de email
login/
page.tsx # Formulario de login
registro/
page.tsx # Formulario de registro
verificar-email/
page.tsx # Pagina de "revisa tu email"
actions.ts # Server Actions de auth
notas/
nueva/
page.tsx # Formulario de nueva nota
[id]/
page.tsx # Detalle de nota
page.tsx # Lista de notas con paginacion
actions.ts # Server Actions de CRUD
perfil/
page.tsx # Perfil del usuario
layout.tsx
page.tsx
components/
BotonEliminar.tsx
NotasRealtime.tsx
lib/
schemas/
nota.ts # Schema de Zod
supabase/
client.ts # Cliente para el navegador
server.ts # Cliente para el servidor
helpers.ts # Utilidades (paginacion, etc.)
errors.ts # Manejo de errores
types/
database.ts # Tipos generados por Supabase CLI
middleware.ts # Proteccion de rutasResumen rapido
Lo que cubrimos en esta guia:
| Tema | Que aprendiste |
|---|---|
| Configuracion | Proyecto Supabase, variables de entorno, paquetes npm |
| Clientes | Server client con cookies, browser client para interactividad |
| CRUD | Insert, select, update, delete con Server Actions |
| Queries | Filtros, ordenamiento, paginacion con range() |
| Real-time | Subscripciones a INSERT, UPDATE, DELETE con WebSockets |
| Auth | Registro, login, logout, callback de email |
| Middleware | Proteccion de rutas, redireccion automatica |
| RLS | Politicas por usuario, contenido publico, roles |
| Validacion | Zod + Supabase para doble validacion |
Siguientes pasos
Desde aqui puedes explorar:
- Storage: subir archivos (imagenes, PDFs) a Supabase Storage
- OAuth: login con Google, GitHub, etc.
- Edge Functions: logica serverless en Deno
- Supabase CLI: migraciones y desarrollo local con
supabase start
La documentacion oficial de Supabase cubre cada uno de estos temas en detalle. Tambien tienen una guia especifica para NextJS que se actualiza con cada version, y el repositorio de GitHub de Supabase donde puedes ver el codigo fuente y reportar bugs.
Preguntas frecuentes
Es Supabase gratis?
Supabase tiene un plan gratuito que incluye 2 proyectos, 500 MB de base de datos, 1 GB de storage, y 50,000 usuarios activos mensuales de auth. Para proyectos personales y MVPs es mas que suficiente. Los planes de pago empiezan en $25 USD al mes.
Puedo usar Supabase sin NextJS?
Si. Supabase funciona con cualquier framework o incluso con JavaScript vanilla. El paquete @supabase/ssr es especifico para frameworks server-side (NextJS, Remix, SvelteKit), pero @supabase/supabase-js funciona en cualquier contexto.
Como hago migraciones de base de datos?
Supabase CLI tiene un sistema de migraciones completo. Instalas supabase con npm install -D supabase, corres npx supabase init, y despues puedes crear migraciones con npx supabase migration new nombre_migracion. Esto genera archivos SQL en supabase/migrations/ que se aplican en orden.
Supabase es seguro para produccion?
Si, pero depende de tu configuracion. RLS es obligatorio, las API keys deben estar en variables de entorno, y debes usar getUser() en vez de getSession() para validar autenticacion en el servidor. Supabase maneja la infraestructura (encriptacion, backups, SSL), pero la logica de acceso la defines tu con politicas de RLS.
Que pasa si Supabase se cae?
Supabase tiene un SLA de 99.9% en planes Pro. Para el plan gratuito no hay SLA garantizado. Si necesitas alta disponibilidad, considera el plan Pro o superior. Tambien puedes self-host Supabase, ya que todo el codigo es open source.
Preguntas frecuentes
Que es Supabase y por que usarlo con NextJS?
Supabase es una alternativa open source a Firebase que te da una base de datos PostgreSQL, autenticacion, storage y funciones en tiempo real. Se integra directamente con NextJS usando el paquete @supabase/ssr, que maneja cookies y sesiones tanto en Server Components como en Client Components.
Como crear un cliente Supabase para Server Components en NextJS?
Usa la funcion createServerClient del paquete @supabase/ssr pasando las variables de entorno NEXT_PUBLIC_SUPABASE_URL y NEXT_PUBLIC_SUPABASE_ANON_KEY, junto con un adaptador de cookies que use la API de cookies de NextJS. Esto permite hacer queries directamente desde Server Components sin exponer datos al cliente.
Que es Row Level Security (RLS) en Supabase y por que es importante?
Row Level Security es una funcionalidad de PostgreSQL que Supabase expone de forma sencilla. Permite definir politicas que controlan que filas puede ver, insertar, actualizar o eliminar cada usuario. Sin RLS activado, cualquier persona con tu anon key podria leer o modificar toda tu base de datos.
Como proteger rutas en NextJS con Supabase Auth?
Crea un archivo middleware.ts en la raiz de tu proyecto que verifique la sesion del usuario usando el cliente Supabase para server. Si no hay sesion activa, redirige al usuario a la pagina de login. Esto se ejecuta antes de que NextJS renderice la pagina, protegiendo tanto Server Components como Client Components.
Puedo usar Supabase real-time con NextJS?
Si. Supabase ofrece subscripciones en tiempo real a traves de WebSockets. En NextJS, necesitas crear un Client Component con 'use client' y usar el metodo channel().on() del cliente Supabase para escuchar cambios INSERT, UPDATE o DELETE en tus tablas.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificacion de firmas, tipado y manejo de errores.