Das Problem: Auth in SSR ist verzwickt
Wenn du Supabase-Auth zum ersten Mal in Next.js 16 (App Router) einsetzt, läufst du fast garantiert in mindestens einen dieser Bugs:
- Login funktioniert, aber nach Refresh ist der User wieder ausgeloggt
- Server-Components sehen den User nicht, Client-Components schon
- Login-Button flackert kurz, obwohl der User schon eingeloggt ist
- RLS-Policies greifen nicht, obwohl der User authenticated ist
Ursache ist immer das Gleiche: Cookies werden zwischen Server und Client nicht korrekt synchronisiert. Hier der saubere Flow, der das alles löst.
Die drei Clients
// lib/supabase/server.ts — für Server-Components & 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(toSet) {
try {
for (const { name, value, options } of toSet) {
cookieStore.set(name, value, options);
}
} catch {
// Called from a Server Component — ignore.
}
},
},
}
);
}
// lib/supabase/client.ts — für 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!
);
}
// lib/supabase/admin.ts — server-only, service_role, NIEMALS im Client
import { createClient as createSb } from "@supabase/supabase-js";
export function createAdminClient() {
return createSb(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
}
Middleware für Session-Refresh
Das ist der Schlüssel — ohne Middleware veraltet die Session schweigend:
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = 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(toSet) {
for (const { name, value } of toSet) request.cookies.set(name, value);
response = NextResponse.next({ request });
for (const { name, value, options } of toSet) {
response.cookies.set(name, value, options);
}
},
},
}
);
// Trigger token refresh if expired
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};
getUser() ist nicht zufällig — der prüft das Token gegen den Auth-Server und löst Refresh aus. getSession() würde das nicht tun (liest nur aus Cookie).
Login-Action (Server Action)
// app/login/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) return { error: error.message };
redirect("/dashboard");
}
Wichtig: kein useState/Client-State für Login-Form nötig. Server Action setzt Cookie, Redirect macht den Rest.
Logout — der Stolperstein
"use server";
export async function logout() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/login");
}
Häufiger Bug: signOut() vom Browser-Client aufrufen, aber Cookies (HttpOnly) bleiben gesetzt. Immer aus Server-Action ausloggen.
RLS + JWT-Claims
Wenn du Admin-Routen brauchst, lies die Rolle aus den JWT-Claims:
const { data: { user } } = await supabase.auth.getUser();
const { data: claims } = await supabase.auth.getClaims();
const role = claims?.claims?.app_metadata?.role;
if (role !== "admin") redirect("/login?error=forbidden");
app_metadata.role setzt du serverseitig via Admin-API. Der Wert kann vom User nicht manipuliert werden (im Gegensatz zu user_metadata).
RLS-Policies
CREATE POLICY user_owns_data ON application_data
FOR ALL TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
Wichtig: Service-Role-Client (admin) ignoriert RLS komplett. Daher nur server-seitig benutzen und Permissions selbst durchsetzen.
Häufige Pitfalls
- Server- und Browser-Client mischen — der Browser-Client speichert in localStorage, der SSR-Client in Cookies. Wenn du beide benutzt, divergieren sie. Immer den richtigen für den Kontext.
getSession()stattgetUser()—getSessionliest nur aus Cookie, validiert nicht.getUsertriggert Refresh und ist trust-bar.- Cookies nicht gesetzt nach Login — Middleware vergessen oder fehlerhaft.
await supabase.auth.getUser()muss in Middleware passieren. - PII in
user_metadata— User kann das selbst überschreiben. Sensitive Daten inapp_metadata(nur via Admin-API editierbar). - Email-Confirmation deaktiviert vergessen — auf Production immer aktivieren, sonst kann sich jeder mit beliebiger E-Mail registrieren.
Logout-Flicker auf protected pages
Schneller User-Check direkt im Layout, BEVOR irgendwas rendert:
// app/(admin)/layout.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function AdminLayout({ children }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
return <>{children}</>;
}
Kein Flicker, kein useEffect, kein Spinner. Funktioniert auch ohne JS.
Nächste Schritte
- Self-Hosted Supabase mit Docker — die DB-Basis
- DevOps-Pipeline mit Coolify — automatisches Deployment
Bei Bedarf an einer fertig konfigurierten Next.js + Supabase-Stack für dein Projekt: Anfrage.
In Praxis
Verwandte Artikel
- DevOps· 1 gemeinsame TagsDevOps-Pipeline mit Coolify, Next.js & GitHub Actions — Auto-Deploy ohne Vendor-Lockin
- DevOps· 1 gemeinsame TagsSelf-Hosted Supabase mit Docker — Setup, Backups & Production-Härtung
- DevOpsCoolify als Heroku-Replacement — Self-Hosted PaaS in der Praxis
- KI / AIpgvector als RAG-Backbone — wann es reicht und wann du eine dedizierte Vector-DB brauchst