Supabase to otwartoźródłowa platforma backendowa zbudowana wokół PostgreSQL. W jednym projekcie dostajesz bazę danych, Auth, Storage, Realtime, Edge Functions i automatycznie generowane API. Dla aplikacji w Next.js to często najkrótsza droga do sensownego MVP bez osobnego backendu pisanego od zera.
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 Server Components renderują się po stronie serwera i mogą pobierać dane bez wysyłania własnego kodu JavaScript 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.
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 Next.js to kod uruchamiany przed obsługą requestu, używany m.in. do redirectów, nagłówków i odświeżania cookies sesji. 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 wykonywana na serwerze, którą można wywołać bezpośrednio z formularza lub komponentu React w App Routerze.. 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, oznacza generowanie HTML na serwerze dla konkretnego żądania. 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, przyspiesza dostarczanie treści z serwerów bliższych użytkownikowi.-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 po czasie bez pełnego rebuildu.,
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.
Podsumowanie
Aktualny stack Supabase + Next.js opiera się na @supabase/ssr, cookies, middleware.ts, Server Actions i RLS. Największy błąd to traktowanie Supabase jak prostego SDK do frontendu. Poprawny model jest inny: frontend i Server Components korzystają z klienta Supabase, ale granice bezpieczeństwa są w bazie, politykach Storage i walidacji po stronie serwera.
Jeśli budujesz MVP albo aplikację produktową z panelem użytkownika, Supabase potrafi skrócić development o tygodnie. Warunek: od początku ustaw RLS, rozdziel klienta server/browser, nie wystawiaj sekretów do przeglądarki i nie zostawiaj uwierzytelniania i autoryzacji "na później".
Tak, szczególnie gdy potrzebujesz szybko zbudować aplikację z uwierzytelnianiem, relacyjną bazą danych, storage i realtime. Najważniejsze jest używanie aktualnego pakietu `@supabase/ssr`, cookies zamiast localStorage w SSR oraz Row Level Security jako głównej warstwy ochrony danych.
Reakcje
Jak Ci się podobało?
Pracuję z tym zawodowo.
Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.
Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.
Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.
Prognozy i trendy 2026: AI, GEO i React Server Components
Siedem trendów, które w 2026 realnie zmieniają web development i marketing: GEO, zero-click search, server-first Next.js, AI-assisted development i agentic commerce.