Tobias Ludwig
Start
ServicesWeb, Apps, Backend und KIPlanerBausteine wählen, Preis sofort sehenBranchen-DemosBeispielseiten für Handwerk, Praxis, GastroReferenzenKundenprojekte mit ErgebnisMatch-CheckPasst das Vorhaben zu mir?Über michWer hinter der Seite steht
ProjekteEigene Software und Open SourceProdukteFertige Apps und WerkzeugeFlutterPlugins und PaketeBlogNotizen aus der Praxis
ToolsKleine Helfer im BrowserTech-WikiWerkzeuge und Stacks, kurz erklärtShowcaseDemos, Shader, Inszenierungen
Support & HilfeHilfe zu laufenden ProjektenMein KontoTickets und Zugänge
Erstgespräch
Erstgespräch buchenKostenloser Kennenlern-CallKontakt schreibenNachricht per Formular
$whoami
Tobias Ludwig
DevOps · Application Manager · Software Engineer
$ls /pages
tobias-ludwig/projekte/kontakt/kalender/impressum/datenschutz/
$git remote
github.com/nexas105
online·next.js 16·hono·postgresql
© 2026 tjl·stay curious_
Ambient OFF
Weiter so… ↓↓ ← → ← → B A
/blog/Fullstack/nextjs-supabase-auth-ssr-flow

Next.js 16 + Supabase Auth — der saubere SSR-Flow ohne flackernde Login-States

Wie man Supabase-Auth in Next.js 16 App Router so integriert, dass keine flackernden Login-States auftreten, Sessions automatisch refreshen und RLS sicher greift. Mit allen Pitfalls rund um Cookies, Middleware und Server-Components.

19. Mai 202610 min Lesezeit774 Wörter
Next.jsSupabaseAuthSSRApp RouterCookiesRLSTypeScript

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() statt getUser() — getSession liest nur aus Cookie, validiert nicht. getUser triggert 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 in app_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

ServiceWeb- und CMS-EntwicklungWebsites und CMS ohne Plugin-Abhängigkeiten und ohne Lizenzkosten.Service ansehen ServiceBackend-EntwicklungAPIs, Datenbanken und Automatisierung.Service ansehen

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
  • GeneralWebsite für Handwerker in Bonn und Rhein-Sieg: Was wirklich Aufträge bringt
  • GeneralNur Google-Profil, keine Website — reicht das 2026?
VorherigerDSGVO-konforme KI-Integration: EU-Hosting, On-Premise-LLMs & Datenschutz-Best-PracticesNächster DevOps-Pipeline mit Coolify, Next.js & GitHub Actions — Auto-Deploy ohne Vendor-Lockin

Neue Artikel via RSS abonnieren

Inhalt
  • Das Problem: Auth in SSR ist verzwickt
  • Die drei Clients
  • Middleware für Session-Refresh
  • Login-Action (Server Action)
  • Logout — der Stolperstein
  • RLS + JWT-Claims
  • RLS-Policies
  • Häufige Pitfalls
  • Logout-Flicker auf protected pages
  • Nächste Schritte
Tags
Next.jsSupabaseAuthSSRApp RouterCookiesRLSTypeScript
In Praxis
  • Web- und CMS-Entwicklung
  • Backend-Entwicklung
RSS-Feed

Neue Artikel im Reader.