tutoriales·18 min de lectura

Supabase con NextJS: guía Completa desde Cero

Aprende a integrar Supabase con NextJS paso a paso. CRUD, autenticación, real-time, Row Level Security y mejores prácticas con código listo para usar.

Supabase con NextJS: guía Completa desde Cero

Esta guía de Supabase con NextJS cubre todo lo que necesitas para construir una aplicación fullstack: desde la configuración inicial hasta autenticación, 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 opción más directa que vas a encontrar.

Vamos a trabajar con el App Router de NextJS, TypeScript, y las librerías oficiales de Supabase. Todo el código es funcional y lo puedes adaptar a tu proyecto.

¿Qué es Supabase?

Supabase es una plataforma open source que te da:

  • Base de datos PostgreSQL completa (no una base NoSQL limitada)
  • Autenticación con email, OAuth, magic links
  • Storage para archivos
  • Funciones Edge (serverless)
  • Real-time por WebSockets
  • API REST automática 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 qué 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: verificación de sesiones antes de renderizar páginas
  • Server Actions: mutaciones seguras desde formularios

Si quieres entender a fondo la diferencia entre Server y Client Components, tengo una guía dedicada al tema.

Crear un proyecto en Supabase

1

Paso 1: Crear cuenta y proyecto

2

Ve a supabase.com y crea una cuenta. después, crea un nuevo proyecto:

3
  1. Click en New Project
  2. Elige una organización (o crea una)
  3. Nombre del proyecto: el que quieras (ejemplo: mi-app)
  4. Password de la base de datos: genera una segura y guardala
  5. región: elige la más cercana a tus usuarios (para LATAM, East US funciona bien)
  6. Click en Create new project
4

Supabase tarda unos 2 minutos en provisionar tu base de datos.

5

Paso 2: Obtener las credenciales

6

Una vez que el proyecto este listo, ve a Settings > API y copia:

7
  • Project URL: algo como https://abcdefghij.supabase.co
  • anon (public) key: una llave JWT larga
Sobre la anon key

La anon key es pública por diseño. 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 guía vamos a configurar RLS.

Configurar el proyecto de NextJS

Si todavia no tienes un proyecto de NextJS, crealo:

bash
npx create-next-app@latest mi-app --typescript --tailwind --eslint --app --src-dir
cd mi-app

Instalar dependencias de Supabase

Necesitas dos paquetes:

bash
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:

bash
NEXT_PUBLIC_SUPABASE_URL=tu_project_url_aqui
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu_anon_key_aqui
Variables 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 también las usa. Si quieres profundizar en como funcionan las variables de entorno en NextJS y Vercel, revisa la guía de variables de entorno.

Asegúrate de que .env.local esté en tu .gitignore (lo está por default en proyectos de NextJS). Nunca subas tus API keys a un repositorio público.

Verifica tus políticas RLS

Verificador de políticas RLS gratuito -- Pega tu SQL y detecta configuraciones inseguras como USING(true) o tablas sin políticas. El análisis corre en tu navegador, tu código no sale de tu máquina.

Crear el cliente Supabase para Server Components

Esta es la parte que genera más confusion. Necesitas dos clientes de Supabase diferentes: uno para el servidor y otro para el navegador. Empecemos por el servidor.

Crea la siguiente estructura:

plaintext
src/
  lib/
    supabase/
      server.ts
      client.ts
      middleware.ts

Cliente para el servidor

typescript
// 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 qué 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 después) se encarga de hacer el refresh correctamente.

cómo usar el cliente en un Server Component

tsx
// 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.título}</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.

typescript
// 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!
  )
}

cómo usar el cliente en un Client Component

tsx
"use client"
 
import { createClient } from "@/lib/supabase/client"
import { useEffect, useState } from "react"
 
interface Nota {
  id: string
  título: 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.título}</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 función async interna porque el callback de useEffect no puede ser async directamente.

CRUD básico con Supabase

Antes de escribir código, 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:

sql
CREATE TABLE notas (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  título 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 más adelante)
ALTER TABLE notas ENABLE ROW LEVEL SECURITY;

Supabase genera automáticamente una API REST a partir de tu schema. Cada tabla que creas se vuelve un endpoint accesible a través 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:

bash
npx supabase gen types typescript --project-id tu_project_id > src/types/database.ts

Esto genera un archivo con todos los tipos de tus tablas. después puedes usarlo así:

typescript
// src/types/database.ts (generado automáticamente, fragmento)
export interface Database {
  public: {
    Tables: {
      notas: {
        Row: {
          id: string
          título: string
          contenido: string | null
          usuario_id: string | null
          created_at: string
          updated_at: string
        }
        Insert: {
          id?: string
          título: string
          contenido?: string | null
          usuario_id?: string | null
          created_at?: string
          updated_at?: string
        }
        Update: {
          id?: string
          título?: string
          contenido?: string | null
          usuario_id?: string | null
          created_at?: string
          updated_at?: string
        }
      }
    }
  }
}

Y al crear el cliente, pasas el tipo:

typescript
// 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:

typescript
// 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 título = formData.get("título") as string
  const contenido = formData.get("contenido") as string
 
  if (!título || título.trim() === "") {
    return { error: "El título es requerido" }
  }
 
  const { data, error } = await supabase
    .from("notas")
    .insert({
      título: título.trim(),
      contenido: contenido?.trim() || null,
    })
    .select()
    .single()
 
  if (error) {
    return { error: error.message }
  }
 
  revalidatePath("/notas")
  return { data }
}

Y el formulario:

tsx
// src/app/notas/nueva/page.tsx
import { crearNota } from "../actions"
 
export default function NuevaNotaPage() {
  return (
    <form action={crearNota}>
      <div>
        <label htmlFor="título">título</label>
        <input
          id="título"
          name="título"
          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 básico con select("*"). aquí hay variaciones útiles:

typescript
// 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, título, 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,
    título,
    contenido,
    created_at,
    usuario:auth.users(email)
  `)

Actualizar datos (UPDATE)

typescript
// 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 título = formData.get("título") as string
  const contenido = formData.get("contenido") as string
 
  const { data, error } = await supabase
    .from("notas")
    .update({
      título: título.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)

typescript
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:

tsx
// 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. aquí van los patrones más comunes.

Filtros

typescript
// Igualdad
const { data } = await supabase
  .from("notas")
  .select("*")
  .eq("usuario_id", userId)
 
// No igual
const { data } = await supabase
  .from("notas")
  .select("*")
  .neq("título", "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("título", "%javascript%")
 
// múltiples 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("título.ilike.%react%,título.ilike.%nextjs%")
 
// NULL check
const { data } = await supabase
  .from("notas")
  .select("*")
  .is("contenido", null)

Ordenamiento

typescript
// 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 })

Paginación

Supabase soporta paginación con range():

typescript
// página 1 (primeros 10 resultados)
const { data, count } = await supabase
  .from("notas")
  .select("*", { count: "exact" })
  .range(0, 9)
  .order("created_at", { ascending: false })
 
// página 2 (resultados 11-20)
const { data } = await supabase
  .from("notas")
  .select("*", { count: "exact" })
  .range(10, 19)
  .order("created_at", { ascending: false })

Un helper de paginación reutilizable:

typescript
// src/lib/supabase/helpers.ts
 
interface PaginacionParams {
  página: number
  porPagina: number
}
 
export function calcularRango({ página, porPagina }: PaginacionParams) {
  const desde = (página - 1) * porPagina
  const hasta = desde + porPagina - 1
  return { desde, hasta }
}
 
// Uso
const { desde, hasta } = calcularRango({ página: 3, porPagina: 10 })
const { data, count } = await supabase
  .from("notas")
  .select("*", { count: "exact" })
  .range(desde, hasta)
  .order("created_at", { ascending: false })

Componente de paginación en Server Component

tsx
// 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<{ página?: string }>
}
 
export default async function NotasPage({ searchParams }: Props) {
  const params = await searchParams
  const paginaActual = Number(params.página) || 1
  const supabase = await createClient()
 
  const { desde, hasta } = calcularRango({
    página: 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.título}</li>
        ))}
      </ul>
 
      <nav className="flex gap-2 mt-4">
        {paginaActual > 1 && (
          <Link href={`/notas?página=${paginaActual - 1}`}>
            Anterior
          </Link>
        )}
        {paginaActual < totalPaginas && (
          <Link href={`/notas?página=${paginaActual + 1}`}>
            Siguiente
          </Link>
        )}
      </nav>
    </div>
  )
}

Subscripciones en tiempo real

Una de las funcionalidades más interesantes de Supabase es el real-time. Puedes escuchar cambios en tu base de datos a través de WebSockets.

Real-time solo en Client Components

Las subscripciones de real-time necesitan una conexión 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:

  1. Ve a Database > Replication
  2. Habilita la tabla notas para real-time

O por SQL:

sql
ALTER PUBLICATION supabase_realtime ADD TABLE notas;

Componente con subscripción

tsx
"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 subscripción cuando el componente se desmonte
    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])
 
  return (
    <ul>
      {notas.map((nota) => (
        <li key={nota.id}>
          <strong>{nota.título}</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:

tsx
// 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 rápida desde el servidor (bueno para SEO), y actualizaciones en vivo en el cliente.

Autenticación con Supabase Auth

Supabase tiene un sistema de autenticación completo. Vamos a implementar el flujo más común: registro e inicio de sesión con email y password.

Server Actions para auth

typescript
// 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 confirmación 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

tsx
// 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 sesión</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 sesión
        </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 aquí
        </a>
      </p>
    </div>
  )
}

Formulario de registro

tsx
// 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:

tsx
// 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 autenticación en el servidor. getSession() lee datos del JWT local que podría estar manipulado. getUser() hace una llamada a Supabase para validar que el usuario realmente existe y su sesión es válida.

Proteger rutas con middleware

El middleware de NextJS se ejecuta antes de que cualquier página se renderice. Es el lugar ideal para verificar autenticación.

typescript
// 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 línea: refresca la sesión del usuario
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  // Rutas que requieren autenticación
  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 estáticos y assets
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
}
cómo funciona este middleware

Cada request pasa por este middleware. Primero, crea un cliente Supabase que puede leer y escribir cookies (para refrescar tokens expirados). después, verifica si el usuario esta autenticado. Si intenta acceder a una ruta protegida sin sesión, lo redirige al login. Si ya tiene sesión y va al login, lo manda a la app.

Flujo completo de autenticación

Para que todo funcione correctamente, necesitas un route handler para el callback de confirmación de email:

typescript
// 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 crítico. 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 básico

RLS funciona con politicas (policies). Cada politica define:

  • Quien puede hacer la acción (autenticado, anonimo, un rol específico)
  • Que acción (SELECT, INSERT, UPDATE, DELETE)
  • Que filas (una condición SQL)

Politicas para la tabla notas

sql
-- 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);
qué es auth.uid()?

auth.uid() es una función de Supabase que retorna el UUID del usuario autenticado que esta haciendo la petición. Supabase extrae esta información del JWT que el cliente envia automáticamente. Si no hay usuario autenticado, auth.uid() retorna null.

Verificar que RLS esta funcionando

Una prueba rápida para confirmar que las politicas estan activas:

sql
-- 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 público

A veces necesitas que ciertos datos sean publicos (por ejemplo, un blog donde cualquiera puede leer los posts):

sql
-- 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):

sql
-- 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 código de NextJS. Si un usuario sabe la URL de tu API de Supabase (qué es pública), puede hacer peticiones directas. RLS es la única barrera real entre tus datos y el mundo exterior. Configuralo siempre.

Insertar con usuario_id automático

En vez de confiar en que el frontend envie el usuario_id correcto, puedes hacer que la base de datos lo asigne automáticamente:

sql
-- Asignar usuario_id automáticamente 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:

sql
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:

typescript
export async function crearNota(formData: FormData) {
  const supabase = await createClient()
 
  const título = formData.get("título") 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({
      título: título.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 válida tipos a nivel de base de datos, pero es buena práctica validar en tu código también. Si usas Zod para validar, puedes crear schemas que validen los datos antes de llegar a Supabase:

typescript
// src/lib/schemas/nota.ts
import { z } from "zod"
 
export const notaSchema = z.object({
  título: z
    .string()
    .min(1, "El título es requerido")
    .max(200, "El título es muy largo"),
  contenido: z
    .string()
    .max(10000, "El contenido es muy largo")
    .nullable()
    .optional(),
})
 
export type NotaInput = z.infer<typeof notaSchema>
typescript
// En tu Server Action
export async function crearNota(formData: FormData) {
  const supabase = await createClient()
 
  const datos = {
    título: formData.get("título") 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 prácticas

después de trabajar con Supabase en producción, estas son las cosas que te van a ahorrar problemas.

1. Siempre habilita RLS

No hay excepción. Incluso en tablas que crees que no necesitan protección. Si una tabla tiene RLS deshabilitado, cualquier persona con tu anon key puede leer y escribir en ella.

sql
-- Hazlo inmediatamente después 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:

bash
npx supabase gen types typescript --project-id tu_project_id > src/types/database.ts

Agregalo como script en tu package.json:

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:

typescript
// 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 acción",
    "23502": "Faltan campos requeridos",
  }
 
  return errores[error.code] || error.message
}

4. No hagas queries desde Client Components si puedes evitarlo

El patron recomendado:

tsx
// 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 excepción es cuándo necesitas real-time o interacciones del usuario que requieren queries dinámicas.

5. Usa variables de entorno por ambiente

En tu proyecto de Vercel, configura variables de entorno diferentes para preview y producción. 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:

sql
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: pública, limitada por RLS. Segura para el navegador.
  • service_role key: bypass de RLS. Nunca la expongas al cliente.
bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://tu-proyecto.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu_anon_key        # pública, ok
SUPABASE_SERVICE_ROLE_KEY=tu_service_role_key      # Sin NEXT_PUBLIC_, solo servidor

La service_role key solo se debe usar en Server Actions, Route Handlers o scripts de migración que corran en el servidor.

Estructura final del proyecto

después de seguir toda la guía, tu proyecto debería verse así:

plaintext
src/
  app/
    auth/
      callback/
        route.ts          # Callback de confirmación de email
      login/
        page.tsx          # Formulario de login
      registro/
        page.tsx          # Formulario de registro
      verificar-email/
        page.tsx          # página 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 paginación
      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 (paginación, etc.)
      errors.ts           # Manejo de errores
  types/
    database.ts           # Tipos generados por Supabase CLI
  middleware.ts           # Protección de rutas

Resumen rápido

Lo que cubrimos en esta guía:

TemaQue aprendiste
ConfiguraciónProyecto Supabase, variables de entorno, paquetes npm
ClientesServer client con cookies, browser client para interactividad
CRUDInsert, select, update, delete con Server Actions
QueriesFiltros, ordenamiento, paginación con range()
Real-timeSubscripciones a INSERT, UPDATE, DELETE con WebSockets
AuthRegistro, login, logout, callback de email
MiddlewareProtección de rutas, redirección automática
RLSPoliticas por usuario, contenido público, roles
ValidaciónZod + Supabase para doble validación

Siguientes pasos

Desde aquí puedes explorar:

  • Storage: subir archivos (imágenes, PDFs) a Supabase Storage
  • OAuth: login con Google, GitHub, etc.
  • Edge Functions: lógica serverless en Deno
  • Supabase CLI: migraciones y desarrollo local con supabase start

La documentación oficial de Supabase cubre cada uno de estos temas en detalle. también tienen una guía específica para NextJS que se actualiza con cada versión, y el repositorio de GitHub de Supabase donde puedes ver el código 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 más 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 específico para frameworks server-side (NextJS, Remix, SvelteKit), pero @supabase/supabase-js funciona en cualquier contexto.

¿Cómo 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 después puedes crear migraciones con npx supabase migration new nombre_migración. Esto genera archivos SQL en supabase/migrations/ que se aplican en orden.

¿Supabase es seguro para producción?

Si, pero depende de tu configuración. RLS es obligatorio, las API keys deben estar en variables de entorno, y debes usar getUser() en vez de getSession() para validar autenticación en el servidor. Supabase maneja la infraestructura (encriptación, backups, SSL), pero la lógica de acceso la defines tu con politicas de RLS.

¿Qué 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. también puedes self-host Supabase, ya que todo el código es open source.

#supabase#nextjs#base-de-datos#autenticación#real-time

Preguntas frecuentes

¿Qué es Supabase y por qué usarlo con NextJS?

Supabase es una alternativa open source a Firebase que te da una base de datos PostgreSQL, autenticación, 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.

¿Cómo crear un cliente Supabase para Server Components en NextJS?

Usa la función 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.

¿Qué es Row Level Security (RLS) en Supabase y por qué 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 podría leer o modificar toda tu base de datos.

¿Cómo proteger rutas en NextJS con Supabase Auth?

Crea un archivo middleware.ts en la raiz de tu proyecto que verifique la sesión del usuario usando el cliente Supabase para server. Si no hay sesión activa, redirige al usuario a la página de login. Esto se ejecuta antes de que NextJS renderice la página, protegiendo tanto Server Components como Client Components.

¿Puedo usar Supabase real-time con NextJS?

Si. Supabase ofrece subscripciones en tiempo real a través de WebSockets. En NextJS, necesitas crear un Client Component con 'use client' y usar el método channel().on() del cliente Supabase para escuchar cambios INSERT, UPDATE o DELETE en tus tablas.