El error más común con la autenticación de Supabase en Next.js App Router es hacer la autenticación solo en el cliente. Las comprobaciones de sesión en el cliente se eluden fácilmente. Aquí te explico cómo hacerlo correctamente: del lado del servidor, con middleware que protege las rutas antes incluso de que se rendericen.
Requisitos previos
- Next.js 14+ con App Router
- Proyecto Supabase con autenticación habilitada
- Paquete
@supabase/ssr(no el antiguoauth-helpers-nextjs)
npm install @supabase/ssr @supabase/supabase-jsConfiguración del cliente Supabase
Tres configuraciones de cliente distintas para tres contextos distintos:
// lib/supabase/server.ts — para Server Components y Route Handlers
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) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}// lib/supabase/middleware.ts — para middleware (mutación de cookies en las peticiones)
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } 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 }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// Refrescar la sesión — necesario antes de llamar a getUser()
const { data: { user } } = await supabase.auth.getUser();
return { supabaseResponse, user };
}// lib/supabase/client.ts — para Client Components
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Middleware: proteger las rutas en el edge
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request);
const isAuthPage = request.nextUrl.pathname.startsWith('/auth');
const isProtected = request.nextUrl.pathname.startsWith('/dashboard') ||
request.nextUrl.pathname.startsWith('/account');
// Redirigir a los usuarios no autenticados fuera de las páginas protegidas
if (isProtected && !user) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = '/auth/login';
redirectUrl.searchParams.set('redirectTo', request.nextUrl.pathname);
return NextResponse.redirect(redirectUrl);
}
// Redirigir a los usuarios autenticados fuera de las páginas de auth
if (isAuthPage && user) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return supabaseResponse;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Página de login con Email + OAuth
// app/auth/login/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ redirectTo?: string }>;
}) {
const { redirectTo } = await searchParams;
async function loginWithEmail(formData: FormData) {
'use server';
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
if (error) redirect(`/auth/login?error=${encodeURIComponent(error.message)}`);
redirect(redirectTo ?? '/dashboard');
}
async function loginWithGitHub() {
'use server';
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (data.url) redirect(data.url);
}
return (
<div className="mx-auto max-w-sm space-y-6">
<form action={loginWithEmail} className="space-y-4">
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Sign in with Email</button>
</form>
<form action={loginWithGitHub}>
<button type="submit">Sign in with GitHub</button>
</form>
</div>
);
}Ruta de callback OAuth
// app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/dashboard';
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(`${origin}${next}`);
}
return NextResponse.redirect(`${origin}/auth/error`);
}Leer el usuario en Server Components
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/auth/login'); // Respaldo por si el middleware no lo capturó
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
return (
<div>
<h1>Welcome, {profile?.full_name ?? user.email}</h1>
</div>
);
}Errores comunes
- Usar
getSession()en lugar degetUser():getSession()devuelve el JWT de la cookie sin validación en el servidor — un atacante puede falsificarlo. Usa siempregetUser()para las comprobaciones de seguridad. - No llamar a
updateSession()en el middleware: sin esto, las cookies expiran y los usuarios son desconectados silenciosamente - Autenticación solo en el cliente: comprobar
supabase.auth.onAuthStateChangeen el cliente no es un control de seguridad, es solo UX - NEXT_PUBLIC frente a claves exclusivas del servidor: nunca pongas tu service role key en una variable
NEXT_PUBLIC_