Tous les articles
·Ingénierie·8 min

Supabase avec Next.js App Router : auth, RLS et types générés

Intégrez Supabase à Next.js App Router : authentification SSR, Row Level Security, types TypeScript générés et données temps réel côté serveur.

Supabase est devenu en 2025-2026 la base de données de référence pour les projets Next.js qui veulent aller vite sans sacrifier la robustesse. PostgreSQL managé, authentification intégrée, Row Level Security, Realtime... mais son intégration avec le modèle SSR de Next.js App Router demande quelques ajustements. Le supabase-js classique ne gère pas les cookies serveur tout seul — il faut @supabase/ssr.

Cet article couvre la configuration complète : client SSR pour les Server Components, authentification avec gestion des sessions dans les cookies, Row Level Security pour sécuriser les données sans dupliquer les vérifications côté serveur, génération de types TypeScript depuis le schéma, et subscriptions temps réel côté client.

Dashboard Supabase montrant une table PostgreSQL avec Row Level Security activé, intégrée dans un projet Next.js App Router

Configurer le client Supabase SSR

Le package @supabase/supabase-js seul ne suffit pas avec Next.js App Router : il ne lit ni n'écrit les cookies HTTP, ce qui casse la gestion de session SSR. @supabase/ssr résout ça en fournissant des utilitaires pour créer un client adapté au contexte (Server Component, Route Handler, Middleware).

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/types/supabase"; // types générés

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient<Database>(
    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 {
            // Dans un Server Component, les cookies ne peuvent pas être modifiés.
            // Le refresh de session est géré côté Middleware.
          }
        },
      },
    }
  );
}
// lib/supabase/client.ts — pour les Client Components uniquement
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/types/supabase";

export function createClient() {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

Le Middleware est indispensable pour rafraîchir le token de session automatiquement entre chaque requête :

// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(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)
          );
        },
      },
    }
  );

  // Refresh de session — ne jamais supprimer cette ligne
  await supabase.auth.getUser();
  return supabaseResponse;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Row Level Security : la sécurité dans la base de données

Row Level Security (RLS) est la fonctionnalité qui distingue Supabase d'un simple BaaS. Chaque requête à la DB passe par les policies définies dans PostgreSQL — l'utilisateur ne peut accéder qu'aux lignes autorisées, même si le code serveur ne filtre pas explicitement.

Exemple pour une table posts où chaque utilisateur ne voit que ses propres données :

-- Activer RLS sur la table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Lecture : chaque user voit uniquement ses posts
CREATE POLICY "users_own_posts_select"
  ON posts FOR SELECT
  USING (auth.uid() = user_id);

-- Insertion : user_id doit correspondre à l'utilisateur connecté
CREATE POLICY "users_own_posts_insert"
  ON posts FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Mise à jour et suppression : même logique
CREATE POLICY "users_own_posts_update"
  ON posts FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "users_own_posts_delete"
  ON posts FOR DELETE
  USING (auth.uid() = user_id);

Le client anon (clé publique) respecte automatiquement les policies RLS. La clé service_role les contourne — à réserver pour les opérations d'administration (migrations, cron jobs) et jamais exposée côté client.

Pour les données publiques (blog ouvert, catalogue produits) : une policy FOR SELECT USING (true) suffit. Pour les données partagées entre membres d'une équipe : les policies peuvent référencer d'autres tables pour vérifier l'appartenance à un workspace ou à un projet.

Authentification SSR avec Supabase Auth

Supabase Auth gère les providers OAuth (Google, GitHub), magic links et mot de passe classique. Dans le App Router, les Server Components accèdent à la session via getUser() — seule option sécurisée côté serveur, car elle valide le JWT auprès de Supabase Auth (contrairement à getSession() qui lit uniquement le cookie sans validation).

// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export default async function DashboardPage() {
  const supabase = await createClient();

  // getUser() valide le token côté serveur — toujours préférer à getSession()
  const {
    data: { user },
    error,
  } = await supabase.auth.getUser();

  if (error || !user) {
    redirect("/login");
  }

  // user.id est garanti et validé — RLS utilisera auth.uid() correspondant
  const { data: posts } = await supabase
    .from("posts")
    .select("id, title, created_at")
    .order("created_at", { ascending: false });

  return <PostList posts={posts ?? []} />;
}

Pour les actions de connexion et déconnexion, les Server Actions sont le pattern naturel :

// lib/actions/auth.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function signIn(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");
}

export async function signOut() {
  const supabase = await createClient();
  await supabase.auth.signOut();
  redirect("/login");
}

Types TypeScript générés depuis le schéma

La Supabase CLI génère automatiquement des types TypeScript depuis votre schéma PostgreSQL. Ce fichier Database devient le contrat entre la base et l'application — plus de chaînes de caractères magiques dans les requêtes.

# Génération des types (Supabase CLI requis)
npx supabase gen types typescript --project-id xxxxxxxxxxx > src/types/supabase.ts

Le type généré ressemble à :

// types/supabase.ts (extrait — généré automatiquement, ne pas éditer manuellement)
export type Database = {
  public: {
    Tables: {
      posts: {
        Row: {
          id: string;
          title: string;
          content: string | null;
          user_id: string;
          created_at: string;
        };
        Insert: {
          id?: string;
          title: string;
          content?: string | null;
          user_id: string;
          created_at?: string;
        };
        Update: Partial<Database["public"]["Tables"]["posts"]["Insert"]>;
      };
    };
  };
};

Une fois ce type injecté dans le client (createServerClient<Database>(...)), toutes les requêtes sont inférées :

// Autocomplétion sur "posts", "title", types de retour inférés automatiquement
const { data } = await supabase
  .from("posts")
  .select("id, title");
// data: Array<{ id: string; title: string }> | null

Automatiser la re-génération lors des migrations avec un script npm :

{
  "scripts": {
    "db:types": "supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > src/types/supabase.ts"
  }
}

Fetch de données dans les Server Components

Avec RLS actif et le client SSR configuré, les Server Components requêtent la DB directement — pas besoin d'un intermédiaire Route Handler pour les lectures simples.

// app/blog/page.tsx — Server Component
import { createClient } from "@/lib/supabase/server";

export default async function BlogPage() {
  const supabase = await createClient();

  // La policy RLS "SELECT public" s'applique automatiquement
  const { data: posts, error } = await supabase
    .from("posts")
    .select(`
      id,
      title,
      created_at,
      author:profiles(name, avatar_url)
    `)
    .eq("published", true)
    .order("created_at", { ascending: false })
    .limit(10);

  if (error) throw error; // Géré par error.tsx

  return <PostGrid posts={posts} />;
}

Pour les données mutées via Server Actions, combiner avec revalidatePath ou revalidateTag. Voir l'article sur le caching Next.js App Router pour les stratégies de revalidation adaptées à Supabase.

Données temps réel dans les Client Components

Le Realtime de Supabase s'appuie sur WebSockets et diffuse les changements de la DB en temps réel. Dans le App Router, les subscriptions se font exclusivement dans les Client Components.

// components/live-messages.tsx
"use client";

import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { Database } from "@/types/supabase";

type Message = Database["public"]["Tables"]["messages"]["Row"];

export function LiveMessages({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const supabase = createClient();

  useEffect(() => {
    // Chargement initial
    supabase
      .from("messages")
      .select("*")
      .eq("room_id", roomId)
      .order("created_at")
      .then(({ data }) => setMessages(data ?? []));

    // Subscription temps réel
    const channel = supabase
      .channel(`room:${roomId}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "messages",
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message]);
        }
      )
      .subscribe();

    // Cleanup à la destruction du composant
    return () => { supabase.removeChannel(channel); };
  }, [roomId, supabase]);

  return (
    <ul>
      {messages.map((msg) => (
        <li key={msg.id}>{msg.content}</li>
      ))}
    </ul>
  );
}

Le RLS s'applique aussi aux subscriptions Realtime — un utilisateur ne reçoit que les events des lignes qu'il est autorisé à lire.

En pratique

L'architecture Supabase + Next.js App Router qui tient en production repose sur trois décisions claires.

Un seul client par contexte. @/lib/supabase/server pour tous les contextes serveur (Server Components, Server Actions, Route Handlers, Middleware). @/lib/supabase/client uniquement dans les Client Components qui ont besoin de Realtime ou d'actions utilisateur directes. Ne jamais importer le client serveur dans un Client Component.

Toujours getUser(), jamais getSession() côté serveur. getSession() lit le cookie sans valider le JWT — un token expiré ou malformé passerait. getUser() effectue une requête réseau à Supabase Auth pour valider : c'est la seule option acceptable pour des décisions d'autorisation.

RLS comme première ligne de défense. Ne pas dupliquer les filtres de sécurité dans chaque requête — RLS les applique de toute façon. L'exception : les opérations service_role (migrations, batch jobs, webhooks admin) où RLS est intentionnellement bypassé.

Pour les projets qui combinent Supabase et un provider d'authentification tiers, l'article sur l'authentification Next.js App Router détaille quand préférer Supabase Auth natif à Clerk ou Better Auth — notamment si vous utilisez le Realtime, où Supabase Auth simplifie la gestion des tokens dans les subscriptions.

Chez Kreio, agence Next.js basée à Évreux (Normandie), on utilise Supabase sur plusieurs projets clients en production. Besoin d'un conseil sur l'architecture de votre stack ? Parlons-en.

Sources