tutoriales·18 min de lectura

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

1

Paso 1: Crear cuenta y proyecto

2

Ve a supabase.com y crea una cuenta. Despues, crea un nuevo proyecto:

3
  1. Click en New Project
  2. Elige una organizacion (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. Region: elige la mas 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 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:

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

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

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.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.

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

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

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

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. Despues puedes usarlo asi:

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

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

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

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, 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)

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

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. Aqui van los patrones mas 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("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

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

Paginacion

Supabase soporta paginacion con range():

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

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

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

  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 subscripcion

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

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

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

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

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 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.

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

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

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);
ℹ️
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:

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 publico

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

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

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

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

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

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 accion",
    "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 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:

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: publica, 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        # Publica, 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 migracion que corran en el servidor.

Estructura final del proyecto

Despues de seguir toda la guia, tu proyecto deberia verse asi:

plaintext
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 rutas

Resumen rapido

Lo que cubrimos en esta guia:

TemaQue aprendiste
ConfiguracionProyecto Supabase, variables de entorno, paquetes npm
ClientesServer client con cookies, browser client para interactividad
CRUDInsert, select, update, delete con Server Actions
QueriesFiltros, ordenamiento, paginacion con range()
Real-timeSubscripciones a INSERT, UPDATE, DELETE con WebSockets
AuthRegistro, login, logout, callback de email
MiddlewareProteccion de rutas, redireccion automatica
RLSPoliticas por usuario, contenido publico, roles
ValidacionZod + 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.

#supabase#nextjs#base-de-datos#autenticacion#real-time

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.