Supabase con NextJS: Setup y Configuración

NextJS tiene múltiples entornos de ejecución: el browser, Server Components, Server Actions, Route Handlers y middleware. Cada uno necesita un cliente de Supabase configurado de forma distinta. El paquete @supabase/ssr se encarga de esto, manejando cookies y sesiones de forma correcta en cada contexto.

Instalar dependencias

bash
npm install @supabase/supabase-js @supabase/ssr
  • @supabase/supabase-js -- el SDK principal
  • @supabase/ssr -- helpers para frameworks server-side como NextJS

Variables de entorno

Crea (o actualiza) tu archivo .env.local:

plaintext
NEXT_PUBLIC_SUPABASE_URL=tu-url-aca
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu-anon-key-aca

En tu código, accedes con process.env.NEXT_PUBLIC_SUPABASE_URL y process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY.

Prefijo NEXT_PUBLIC_

Las variables con prefijo NEXT_PUBLIC_ son visibles en el browser. Esto es correcto para la URL y la anon key. Nunca uses este prefijo para la service_role key.

Los tres clientes de Supabase

En una app de NextJS con App Router, necesitas crear el cliente de Supabase de forma diferente según donde lo uses. Esto es porque cada contexto tiene una forma distinta de acceder a las cookies (que es dónde vive la sesión del usuario).

1. Cliente para el browser

Se usa en Client Components (archivos con 'use client'):

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

Uso en un Client Component:

tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
 
export function ListaProductos() {
  const [productos, setProductos] = useState<any[]>([])
  const supabase = createClient()
 
  useEffect(() => {
    async function cargar() {
      const { data } = await supabase
        .from('productos')
        .select('*')
      setProductos(data ?? [])
    }
    cargar()
  }, [])
 
  return (
    <ul>
      {productos.map((p) => (
        <li key={p.id}>{p.nombre}</li>
      ))}
    </ul>
  )
}

2. Cliente para el servidor

Se usa en Server Components, Server Actions y Route Handlers. Necesita acceso a las cookies del request para leer la sesión:

typescript
// 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 (son read-only)
            // Esto es esperado y no afecta la lectura de la sesión
          }
        },
      },
    }
  )
}
Por que el try/catch en setAll

En Server Components, las cookies son de solo lectura. No puedes escribir cookies ahí. El try/catch evita que el error rompa tu página. La escritura de cookies sí funciona en Server Actions, Route Handlers y middleware.

3. Cliente para middleware

El middleware corre antes de cada request y es donde Supabase refresca la sesión (renueva el JWT si está por expirar):

typescript
// lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function updateSession(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)
          )
        },
      },
    }
  )
 
  // IMPORTANTE: No escribas codigo entre createServerClient y getUser()
  // Un simple getUser() refresca la sesión si es necesario
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  return supabaseResponse
}

Configurar el middleware

Crea el archivo middleware.ts en la raíz de tu proyecto (junto a app/):

typescript
// middleware.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
 
export async function middleware(request: NextRequest) {
  return await updateSession(request)
}
 
export const config = {
  matcher: [
    /*
     * Matchea todas las rutas excepto:
     * - _next/static (archivos estáticos)
     * - _next/image (optimización de imágenes)
     * - favicon.ico (favicon)
     * - Archivos con extension (imagenes, SVGs, etc.)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
El middleware es obligatorio

Sin el middleware, las sesiones de Supabase no se refrescan automáticamente. Cuando el JWT expire (por defecto después de 1 hora), el usuario va a parecer deslogueado aunque tenga una sesión válida. El middleware renueva el token de forma transparente.

Cómo funciona el flujo de cookies

Entender el flujo de cookies te ahorra horas de debugging:

  1. El usuario se loguea (via email, OAuth, etc.)
  2. Supabase devuelve un JWT (access token) y un refresh token
  3. El SDK los guarda en cookies del browser
  4. En cada request, el middleware lee las cookies, refresca el token si es necesario, y setea las cookies actualizadas en la respuesta
  5. Server Components leen las cookies para saber quién es el usuario
  6. Client Components usan el cliente del browser que lee las mismas cookies
plaintext
Browser                    Middleware                Server Component
  |                           |                          |
  |-- Request con cookies --> |                          |
  |                           |-- Leer cookies           |
  |                           |-- Refrescar JWT          |
  |                           |-- Setear nuevas cookies  |
  |                           |                          |
  |                           |--------- Request ------> |
  |                           |                          |-- Leer cookies
  |                           |                          |-- getUser()
  |                           |                          |-- Query con RLS
  |                           |                          |
  |<-- Response con cookies --|<-------- Response -------|

Proteger rutas

Puedes extender el middleware para redirigir usuarios no autenticados:

typescript
// middleware.ts
import { type NextRequest, NextResponse } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
 
const rutasProtegidas = ['/dashboard', '/perfil', '/configuracion']
 
export async function middleware(request: NextRequest) {
  const response = await updateSession(request)
 
  // Verificar si la ruta requiere autenticación
  const esRutaProtegida = rutasProtegidas.some(
    (ruta) => request.nextUrl.pathname.startsWith(ruta)
  )
 
  if (esRutaProtegida) {
    // Re-crear el cliente para verificar auth
    // (updateSession ya refresco la sesión)
    const { createServerClient } = await import('@supabase/ssr')
    const supabase = createServerClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      {
        cookies: {
          getAll() {
            return request.cookies.getAll()
          },
          setAll() {},
        },
      }
    )
 
    const { data: { user } } = await supabase.auth.getUser()
 
    if (!user) {
      const url = request.nextUrl.clone()
      url.pathname = '/login'
      url.searchParams.set('redirect', request.nextUrl.pathname)
      return NextResponse.redirect(url)
    }
  }
 
  return response
}
 
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Estructura de archivos recomendada

plaintext
lib/
  supabase/
    client.ts       # createBrowserClient (browser)
    server.ts       # createServerClient (Server Components, Actions)
    middleware.ts    # updateSession (middleware)
middleware.ts        # Archivo de middleware de NextJS
No uses un solo cliente

Es tentador crear un solo archivo lib/supabase.ts con createClient() como en la sección de instalación. Ese approach funciona para queries simples, pero no maneja cookies ni sesiones correctamente. Con @supabase/ssr y los tres clientes separados, auth funciona bien en todos los contextos.

Problemas comunes

"Auth session missing"

El middleware no está corriendo o no está configurado. Verifica que middleware.ts existe en la raíz del proyecto y que el matcher no excluye tus rutas.

La sesión se pierde al navegar

El middleware no está refrescando las cookies. Verifica que supabase.auth.getUser() se llama dentro de updateSession y que las cookies se setean en la respuesta.

"cookies() can only be called in a Server Component"

Estás importando el cliente del servidor en un Client Component. Usa createBrowserClient de @supabase/ssr en archivos con 'use client'.


Resumen

ContextoPaqueteFunciónArchivo
Browser (Client Components)@supabase/ssrcreateBrowserClientlib/supabase/client.ts
Server Components / Actions@supabase/ssrcreateServerClient + cookies()lib/supabase/server.ts
Middleware@supabase/ssrcreateServerClient + request cookieslib/supabase/middleware.ts