Supabase en Server Components de NextJS

Los Server Components son el contexto ideal para hacer queries a Supabase. Se ejecutan en el servidor, no envian JavaScript al browser, y pueden acceder a las cookies directamente para leer la sesión del usuario. En esta guía vas a aprender a fetchear datos, verificar autenticación y pasar información a Client Components.

Setup previo

Antes de continuar, necesitas tener configurados los tres clientes de Supabase como se explica en la sección anterior. En particular, el cliente del servidor en lib/supabase/server.ts:

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 {
            // Esperado en Server Components (read-only)
          }
        },
      },
    }
  )
}

Fetch de datos en Server Components

Un Server Component que lee datos de Supabase se ve así:

tsx
// app/productos/page.tsx
import { createClient } from '@/lib/supabase/server'
 
export default async function ProductosPage() {
  const supabase = await createClient()
 
  const { data: productos, error } = await supabase
    .from('productos')
    .select('id, nombre, precio, imagen_url')
    .eq('activo', true)
    .order('created_at', { ascending: false })
 
  if (error) {
    return <p>Error al cargar productos: {error.message}</p>
  }
 
  if (!productos || productos.length === 0) {
    return <p>No hay productos disponibles.</p>
  }
 
  return (
    <div>
      <h1>Productos</h1>
      <ul>
        {productos.map((producto) => (
          <li key={producto.id}>
            <h2>{producto.nombre}</h2>
            <p>${producto.precio}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

Esto se ejecuta completamente en el servidor. El browser recibe HTML puro -- sin fetch requests, sin loading states, sin JavaScript adicional.

Autenticación con getUser()

Para saber quién es el usuario actual en un Server Component, usa supabase.auth.getUser():

tsx
// 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 }, error } = await supabase.auth.getUser()
 
  if (error || !user) {
    redirect('/login')
  }
 
  return (
    <div>
      <h1>Tu 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() en vez de getSession() en el servidor. getUser() valida el JWT contra los servidores de Supabase, mientras que getSession() solo lee el token local sin validar. En un Server Component, necesitas la validación real.

Queries con RLS y el usuario autenticado

Cuando creas el cliente del servidor con las cookies del request, el SDK automáticamente incluye el JWT del usuario en las queries. Esto significa que las políticas de RLS se aplican correctamente:

tsx
// app/mis-pedidos/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function MisPedidosPage() {
  const supabase = await createClient()
 
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  // Esta query respeta RLS
  // Si tienes una política "usuarios ven sus propios pedidos",
  // solo devuelve los pedidos del usuario actual
  const { data: pedidos, error } = await supabase
    .from('pedidos')
    .select(`
      id,
      total,
      estado,
      created_at,
      items:pedido_items(
        cantidad,
        producto:productos(nombre, precio)
      )
    `)
    .order('created_at', { ascending: false })
 
  if (error) {
    return <p>Error al cargar pedidos.</p>
  }
 
  return (
    <div>
      <h1>Mis pedidos</h1>
      {pedidos.length === 0 ? (
        <p>Todavía no tienes pedidos.</p>
      ) : (
        <ul>
          {pedidos.map((pedido) => (
            <li key={pedido.id}>
              <p>Pedido #{pedido.id}</p>
              <p>Total: ${pedido.total}</p>
              <p>Estado: {pedido.estado}</p>
              <p>{pedido.items.length} productos</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

No necesitas filtrar manualmente por usuario_id. RLS se encarga de que cada usuario solo vea sus propios datos.

Pasar datos a Client Components

Los Server Components no pueden tener interactividad (useState, onClick, etc.). Cuando necesitas interactividad con datos de Supabase, fetchea en el Server Component y pasa los datos como props:

tsx
// app/dashboard/page.tsx (Server Component)
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { DashboardClient } from './dashboard-client'
 
export default async function DashboardPage() {
  const supabase = await createClient()
 
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  // Fetch en el servidor
  const { data: estadisticas } = await supabase
    .from('estadisticas_usuario')
    .select('*')
    .eq('usuario_id', user.id)
    .single()
 
  const { data: notificaciones } = await supabase
    .from('notificaciones')
    .select('*')
    .eq('usuario_id', user.id)
    .eq('leida', false)
    .order('created_at', { ascending: false })
    .limit(5)
 
  // Pasar datos al Client Component
  return (
    <DashboardClient
      usuario={user}
      estadisticas={estadisticas}
      notificaciones={notificaciones ?? []}
    />
  )
}
tsx
// app/dashboard/dashboard-client.tsx (Client Component)
'use client'
 
import { useState } from 'react'
import type { User } from '@supabase/supabase-js'
 
interface DashboardClientProps {
  usuario: User
  estadisticas: {
    total_pedidos: number
    total_gastado: number
    productos_favoritos: number
  } | null
  notificaciones: {
    id: string
    mensaje: string
    created_at: string
  }[]
}
 
export function DashboardClient({
  usuario,
  estadisticas,
  notificaciones,
}: DashboardClientProps) {
  const [mostrarNotificaciones, setMostrarNotificaciones] = useState(false)
 
  return (
    <div>
      <h1>Hola, {usuario.email}</h1>
 
      {estadisticas && (
        <div>
          <p>Total de pedidos: {estadisticas.total_pedidos}</p>
          <p>Total gastado: ${estadisticas.total_gastado}</p>
        </div>
      )}
 
      <button onClick={() => setMostrarNotificaciones(!mostrarNotificaciones)}>
        Notificaciones ({notificaciones.length})
      </button>
 
      {mostrarNotificaciones && (
        <ul>
          {notificaciones.map((n) => (
            <li key={n.id}>{n.mensaje}</li>
          ))}
        </ul>
      )}
    </div>
  )
}
Patrón recomendado

Fetchea datos en Server Components, pasa como props a Client Components. Así separas las responsabilidades: el servidor se encarga de los datos y la seguridad, el cliente se encarga de la interactividad.

Consideraciones de caching

NextJS cachea los Server Components por defecto. Esto puede causar que los datos de Supabase no se actualicen al navegar entre páginas.

Forzar datos frescos

tsx
// Opcion 1: Configuracion de la ruta
export const dynamic = 'force-dynamic'
 
// Opcion 2: revalidar cada N segundos
export const revalidate = 60 // Revalidar cada 60 segundos

Cuándo usar cada estrategia

EscenarioEstrategia
Datos del usuario (perfil, pedidos)force-dynamic -- siempre frescos
Catalogo de productosrevalidate = 300 -- cada 5 minutos
Página pública sin authDejar el cache por defecto
Dashboard con métricasforce-dynamic
tsx
// app/mis-pedidos/page.tsx
import { createClient } from '@/lib/supabase/server'
 
// Los pedidos del usuario deben ser siempre frescos
export const dynamic = 'force-dynamic'
 
export default async function MisPedidosPage() {
  const supabase = await createClient()
  // ...
}
Cuidado con el cache y auth

Si una página muestra datos específicos del usuario, siempre usa dynamic = 'force-dynamic'. Sin esto, NextJS podría cachear la página del usuario A y mostrársela al usuario B.

Ejemplo completo: página de perfil con datos del usuario

tsx
// app/perfil/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { PerfilForm } from './perfil-form'
 
export const dynamic = 'force-dynamic'
 
export default async function PerfilPage() {
  const supabase = await createClient()
 
  // Verificar autenticacion
  const { data: { user }, error: authError } = await supabase.auth.getUser()
 
  if (authError || !user) {
    redirect('/login?redirect=/perfil')
  }
 
  // Traer el perfil extendido del usuario
  const { data: perfil, error: perfilError } = await supabase
    .from('perfiles')
    .select('nombre, bio, avatar_url, sitio_web')
    .eq('id', user.id)
    .single()
 
  if (perfilError) {
    return <p>Error al cargar tu perfil.</p>
  }
 
  // Traer estadisticas
  const { count: totalPosts } = await supabase
    .from('posts')
    .select('*', { count: 'exact', head: true })
    .eq('autor_id', user.id)
 
  const { count: totalComentarios } = await supabase
    .from('comentarios')
    .select('*', { count: 'exact', head: true })
    .eq('autor_id', user.id)
 
  return (
    <div>
      <h1>Tu perfil</h1>
 
      <div>
        <p>Email: {user.email}</p>
        <p>Miembro desde: {new Date(user.created_at).toLocaleDateString('es-MX')}</p>
        <p>{totalPosts ?? 0} posts publicados</p>
        <p>{totalComentarios ?? 0} comentarios</p>
      </div>
 
      {/* Client Component para editar el perfil */}
      <PerfilForm
        perfilInicial={{
          nombre: perfil.nombre ?? '',
          bio: perfil.bio ?? '',
          sitioWeb: perfil.sitio_web ?? '',
        }}
      />
    </div>
  )
}

El Server Component hace 3 queries a Supabase (perfil, posts, comentarios), todo en el servidor. El usuario recibe la página ya renderizada. El formulario de edición es un Client Component que usa Server Actions para guardar los cambios (eso lo cubrimos en la siguiente sección).


Resumen

ConceptoDetalle
ClientecreateClient() de lib/supabase/server.ts
Authsupabase.auth.getUser() (valida JWT en el servidor)
RLSSe aplica automáticamente con el JWT del usuario
InteractividadFetchear en Server Component, pasar props a Client Component
Cacheforce-dynamic para datos del usuario, revalidate para datos públicos
PatrónServer Component = datos + seguridad, Client Component = interactividad