W 2026 roku najważniejsza zmiana w tym stacku jest prosta: nie zaczynaj od starych Auth Helpers. Do integracji z App Routerem używaj @supabase/ssr, cookies, osobnych klientów server/browser i middleware.ts, który odświeża sesję dla React Server Components — komponenty renderowane wyłącznie na serwerze, które mogą być funkcjami async i pobierać dane bezpośrednio, bez wysyłania własnego JavaScriptu do przeglądarki..
Druga zasada: Supabase nie zwalnia z myślenia o bezpieczeństwie. Publiczny klient w przeglądarce jest normalną częścią architektury, ale dane chroni baza przez Row Level Security to mechanizm PostgreSQL, który ogranicza dostęp do konkretnych wierszy tabeli na podstawie polityk dostępu.. Jeśli RLS jest źle ustawione, problemu nie naprawi elegancki komponent Reacta.
Warto widzieć ten stack jako kilka warstw kontroli dostępu, a nie jedno SDK wywoływane z Reacta. Middleware dba o świeżą sesję, kod serwerowy sprawdza użytkownika, a baza i polityki Supabase egzekwują dostęp do danych.
Diagram
Warstwy dostępu w aplikacji Next.js + Supabase
Setup projektu
Instalacja pakietów jest krótka:
Code
npm install @supabase/supabase-js @supabase/ssr
W nowych projektach Supabase promuje publishable keys:
Jeśli pracujesz na starszym projekcie, możesz jeszcze spotkać NEXT_PUBLIC_SUPABASE_ANON_KEY. To nadal może działać, ale przy nowym setupie warto przejść na Publishable key to publiczny klucz projektu Supabase przeznaczony do użycia w aplikacji klienckiej; jego bezpieczeństwo opiera się na RLS i politykach dostępu., bo to aktualny kierunek dokumentacji Supabase.
Nigdy nie dodawaj `service_role` to sekretny klucz Supabase z uprawnieniami administracyjnymi, który omija Row Level Security i nie może trafić do przeglądarki. jako zmiennej NEXT_PUBLIC_*. Ten klucz omija RLS i może istnieć tylko w bezpiecznym kontekście serwerowym, np. w zamkniętym Route Handlerze, zadaniu administracyjnym albo Edge Function.
Klient Supabase dla App Routera
W App Routerze potrzebujesz co najmniej dwóch klientów: jednego do kodu przeglądarkowego i jednego do kodu serwerowego.
Code
// lib/supabase/client.tsimport { createBrowserClient } from '@supabase/ssr'export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, )}
Code
// lib/supabase/server.tsimport { 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_PUBLISHABLE_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => { cookieStore.set(name, value, options) }) } catch { // Server Components nie mogą zapisywać cookies. // Odświeżanie sesji obsługuje middleware.ts. } }, }, }, )}
W większym projekcie dodaj typy wygenerowane z bazy:
Code
npx supabase gen types typescript --project-id "$PROJECT_REF" > src/types/database.types.ts
Potem możesz stworzyć klienta jako createServerClient<Database>(...) i dostać typowane from('posts'), nazwy kolumn oraz relacje.
Middleware do odświeżania sesji
W kontekście Supabase Middleware w Astro to kod uruchamiany przy każdym żądaniu, zanim wyrenderuje się strona — może czytać żądanie, modyfikować context.locals, przekierowywać albo przepuścić żądanie dalej. jest potrzebny głównie dlatego, że Server Components nie mogą samodzielnie zapisać odświeżonych cookies.
Warto rozdzielić plik konwencji Next.js od logiki Supabase:
Code
// middleware.tsimport { type NextRequest } from 'next/server'import { updateSession } from '@/lib/supabase/middleware'export async function middleware(request: NextRequest) { return await updateSession(request)}export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ],}
Middleware może robić wstępne przekierowania dla /dashboard, ale nie traktuj go jako pełnego systemu autoryzacji. Next.js uruchamia go przed renderowaniem i sam Middleware nie powinien stać się miejscem ciężkiej logiki biznesowej.
Uwierzytelnianie w Server Actions
Logowanie hasłem możesz zrobić w Client Component przez signInWithPassword, ale w App Routerze coraz częściej wygodniejszy jest Server Action to funkcja oznaczona dyrektywą 'use server', wykonywana na serwerze, ale wywoływana bezpośrednio z komponentu klienckiego. Tutaj odbiera kompletne dane formularza, ponownie waliduje je Zodem i zapisuje — bez ręcznego pisania endpointu API.. Formularz pozostaje prosty, cookies są ustawiane po stronie serwera, a po mutacji możesz odświeżyć cache.
W prawdziwej aplikacji dodaj walidację Zod, obsługę błędów przez useActionState, limity prób logowania i sensowny UX dla potwierdzania adresu e-mail. Przykład powyżej pokazuje przepływ, nie kompletny formularz produkcyjny.
OAuth i callback
OAuth nadal zaczyna się po stronie klienta, bo użytkownik musi zostać przekierowany do dostawcy (providera):
Code
'use client'import { createClient } from '@/lib/supabase/client'export function GoogleLoginButton() { const supabase = createClient() async function signInWithGoogle() { await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: `${window.location.origin}/auth/callback`, }, }) } return <button onClick={signInWithGoogle}>Kontynuuj z Google</button>}
Callback wymienia code na sesję:
Code
// app/auth/callback/route.tsimport { createClient } from '@/lib/supabase/server'import { NextResponse } from 'next/server'export async function GET(request: Request) { const url = new URL(request.url) const code = url.searchParams.get('code') const next = url.searchParams.get('next') ?? '/dashboard' if (code) { const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { return NextResponse.redirect(new URL(next, url.origin)) } } return NextResponse.redirect(new URL('/login?error=auth', url.origin))}
Ochrona stron i danych
Nie opieraj ochrony panelu wyłącznie na tym, że użytkownik nie widzi linku w nawigacji. Sprawdzenie musi odbywać się po stronie serwera.
Code
// app/dashboard/page.tsximport { 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('/login') } return <div>Panel użytkownika: {user.email}</div>}
W Server Actions powtarzaj ten sam typ kontroli. To, że formularz znajduje się na chronionej stronie, nie oznacza, że wywołanie akcji jest automatycznie bezpieczne.
// app/dashboard/posts/actions.ts'use server'import { createClient } from '@/lib/supabase/server'import { revalidatePath } from 'next/cache'import { z } from 'zod'const createPostSchema = z.object({ title: z.string().trim().min(3).max(120), content: z.string().trim().min(10).max(20000),})export async function createPost(formData: FormData) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return { error: 'Musisz być zalogowany.' } } const parsed = createPostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), }) if (!parsed.success) { return { error: 'Popraw dane formularza.' } } const { error } = await supabase.from('posts').insert({ ...parsed.data, author_id: user.id, published: false, }) if (error) { return { error: error.message } } revalidatePath('/dashboard/posts') return { success: true }}
Nawet jeśli w insert() podstawiasz author_id na podstawie sesji, RLS nadal powinno to egzekwować. Kod aplikacji i polityki w bazie mają się wzajemnie uzupełniać.
Row Level Security
RLS w Supabase działa jak automatyczny filtr dodawany do zapytań. Po włączeniu RLS tabela nie zwróci danych przez publiczne API, dopóki nie dodasz polityk.
Code
alter table public.posts enable row level security;create policy "Authenticated users can read their own posts"on public.postsfor selectto authenticatedusing ((select auth.uid()) = author_id);create policy "Authenticated users can create their own posts"on public.postsfor insertto authenticatedwith check ((select auth.uid()) = author_id);create policy "Authenticated users can update their own posts"on public.postsfor updateto authenticatedusing ((select auth.uid()) = author_id)with check ((select auth.uid()) = author_id);create policy "Authenticated users can delete their own posts"on public.postsfor deleteto authenticatedusing ((select auth.uid()) = author_id);
Dla publicznych wpisów dodaj osobną politykę tylko do odczytu:
Code
create policy "Anyone can read published posts"on public.postsfor selectto anon, authenticatedusing (published = true);
Warto jawnie podawać role przez to authenticated albo to anon, authenticated. Jest to czytelniejsze i często wydajniejsze niż polityki bez określonej roli.
Storage — upload plików
Najprostszy wariant dla avatara to upload z klienta do bucketu avatars, z polityką ograniczającą ścieżkę do katalogu użytkownika.
Code
insert into storage.buckets (id, name, public)values ('avatars', 'avatars', true)on conflict (id) do nothing;create policy "Users can upload their own avatar"on storage.objectsfor insertto authenticatedwith check ( bucket_id = 'avatars' and (select auth.uid())::text = (storage.foldername(name))[1]);create policy "Users can update their own avatar"on storage.objectsfor updateto authenticatedusing ( bucket_id = 'avatars' and (select auth.uid())::text = (storage.foldername(name))[1]);create policy "Anyone can read avatars"on storage.objectsfor selectto anon, authenticatedusing (bucket_id = 'avatars');
Walidacja w komponencie poprawia UX, ale nie jest granicą bezpieczeństwa. Granicą jest polityka Storage, limity bucketu i ewentualna kontrola po stronie serwera. Dla wrażliwych plików zamiast publicznego bucketu użyj prywatnego bucketu oraz podpisanych URL-i.
Realtime — subskrypcje na żywo
Realtime dla zmian w Postgresie wymaga dodania tabeli do publikacji:
Code
alter publication supabase_realtime add table public.comments;
Tabela nadal powinna mieć RLS:
Code
alter table public.comments enable row level security;create policy "Users can read comments for visible posts"on public.commentsfor selectto authenticatedusing ( exists ( select 1 from public.posts where posts.id = comments.post_id and posts.author_id = (select auth.uid()) ));
Przy Realtime łatwo przesadzić. Nie subskrybuj całych tabel, jeśli potrzebujesz zmian tylko dla jednego dokumentu, projektu albo organizacji. Filtr w subskrypcji zmniejsza ruch i ryzyko przypadkowego ujawnienia informacji.
Cache i prywatne dane
Supabase Auth z SSR, czyli Server-Side Rendering, to generowanie HTML na serwerze przy żądaniu — komponent client:only je pomija i renderuje się wyłącznie w przeglądarce. korzysta z cookies, a to oznacza, że odpowiedzi zawierające dane użytkownika nie powinny być traktowane jak statyczne strony ani cache'owane publicznie za CDN, czyli Content Delivery Network, to rozproszona sieć serwerów dostarczająca zasoby z węzła najbliższego użytkownikowi; CDN do obrazów dodatkowo transformuje je w locie.-em.
Praktyczne reguły:
strony panelu użytkownika trzymaj jako dynamiczne,
prywatnych danych nie pobieraj w generateStaticParams,
po Server Actions używaj revalidatePath() albo revalidateTag(),
nie mieszaj prywatnych danych użytkownika z publicznym ISR, czyli Incremental Static Regeneration, pozwala odświeżać strony statyczne w tle bez pełnego rebuildu — strona jest serwowana z cache, a Next.js regeneruje ją po upływie czasu revalidate.,
w Route Handlers jasno ustawiaj nagłówki cache, jeśli odpowiedź zależy od sesji.
To szczególnie ważne, gdy aplikacja działa za CDN-em lub używa agresywnego cache'owania odpowiedzi HTML.
Kiedy Supabase ma sens, a kiedy nie?
Supabase jest bardzo dobry dla:
MVP i SaaS z relacyjnym modelem danych,
paneli użytkownika, CRM, marketplace'ów i aplikacji B2B,
aplikacji, w których Postgres, RLS i szybki CRUD są ważniejsze niż własna infrastruktura,
produktów, które potrzebują Auth, Storage i Realtime bez składania kilku usług.
Rozważyłbym inne podejście, gdy:
masz bardzo specyficzne wymagania infrastrukturalne albo compliance,
logika domenowa jest ciężka i wymaga osobnego backendu,
Realtime ma obsługiwać bardzo duże wolumeny zdarzeń z własnym protokołem,
zespół już ma dojrzały backend w NestJS, Rails, Django, Laravel albo Go.
Supabase nie jest "backendiem bez backendu". To raczej zarządzany Postgres z zestawem usług, które pozwalają przesunąć dużo pracy z warstwy aplikacyjnej do bazy i polityk dostępu.
Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.