Przejdź do treści

Integracja Supabase z Next.js App Router

Poznaj możliwości połączenia Next.js i Supabase. Sprawdź, jak bezpiecznie zarządzać sesją użytkownika, bazą danych i subskrypcjami Realtime.

Maciej Sala

Founder StriveLab

8 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 23 lipca 2026)

Czym jest Supabase i dlaczego pasuje do Next.js?

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 proxy.ts, który odświeża sesję dla .

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 . 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. Proxy 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 Supabase i Next.js

Instalacja pakietów jest krótka:

Code
npm install @supabase/supabase-js @supabase/ssr

Pakiet @supabase/ssr jest rekomendowanym następcą Auth Helpers, ale nadal ma status beta. Przed aktualizacją sprawdzaj changelog, bo jego API może jeszcze otrzymywać zmiany niekompatybilne wstecz.

W nowych projektach Supabase promuje publishable keys:

Code
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://twoj-projekt.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx

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 , bo to aktualny kierunek dokumentacji Supabase.

Nigdy nie dodawaj nowego klucza sb_secret_... ani legacy jako zmiennej NEXT_PUBLIC_*. Oba zapewniają podwyższone uprawnienia i omijają RLS, więc mogą istnieć tylko w bezpiecznym kontekście serwerowym, np. w chronionym Route Handlerze, zadaniu administracyjnym albo Edge Function. W nowym projekcie używaj secret key; service_role jest kluczem legacy.

Klient Supabase dla Next.js App Router

W App Routerze potrzebujesz co najmniej dwóch klientów: jednego do kodu przeglądarkowego i jednego do kodu serwerowego.

Code
// lib/supabase/client.ts
import { 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.ts
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_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet, _headers) {
          try {
            cookiesToSet.forEach(({ name, value, options }) => {
              cookieStore.set(name, value, options)
            })
          } catch {
            // Server Components nie mogą zapisywać cookies.
            // Odświeżanie sesji obsługuje proxy.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.

Proxy do odświeżania sesji Supabase Auth

Od Next.js 16 konwencja middleware.ts została przemianowana na proxy.ts. W kontekście Supabase mechanizm Proxy 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
// proxy.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
 
export async function proxy(request: NextRequest) {
  return await updateSession(request)
}
 
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
Code
// lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function updateSession(request: NextRequest) {
  let response = NextResponse.next({ request })
 
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) => {
            request.cookies.set(name, value)
          })
 
          response = NextResponse.next({ request })
 
          cookiesToSet.forEach(({ name, value, options }) => {
            response.cookies.set(name, value, options)
          })
 
          Object.entries(headers).forEach(([key, value]) => {
            response.headers.set(key, value)
          })
        },
      },
    },
  )
 
  // Nie dodawaj logiki między createServerClient() i getClaims().
  // To wywołanie weryfikuje JWT i w razie potrzeby odświeża sesję.
  await supabase.auth.getClaims()
 
  return response
}

Drugi argument setAll() zawiera między innymi nagłówki zabezpieczające odpowiedź z odświeżoną sesją przed publicznym cache'owaniem. Trzeba przenieść je na zwracany NextResponse, tak samo jak cookies.

Proxy może robić wstępne przekierowania dla /dashboard, ale nie traktuj go jako pełnego systemu autoryzacji. Next.js uruchamia Proxy przed renderowaniem, a ten mechanizm nie powinien stać się miejscem ciężkiej logiki biznesowej.

Uwierzytelnianie Supabase w Server Actions

Logowanie hasłem możesz zrobić w Client Component przez signInWithPassword, ale w App Routerze coraz częściej wygodniejszy jest . Formularz pozostaje prosty, cookies są ustawiane po stronie serwera, a po mutacji możesz odświeżyć cache.

Code
// app/login/actions.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
 
export type AuthState = {
  error: string | null
}
 
export async function login(
  _previousState: AuthState,
  formData: FormData,
): Promise<AuthState> {
  const supabase = await createClient()
 
  const email = String(formData.get('email') ?? '')
  const password = String(formData.get('password') ?? '')
 
  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })
 
  if (error) {
    return { error: 'Nie udało się zalogować. Sprawdź adres e-mail i hasło.' }
  }
 
  revalidatePath('/', 'layout')
  redirect('/dashboard')
}
 
export async function signup(
  _previousState: AuthState,
  formData: FormData,
): Promise<AuthState> {
  const supabase = await createClient()
 
  const email = String(formData.get('email') ?? '')
  const password = String(formData.get('password') ?? '')
 
  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })
 
  if (error) {
    return { error: 'Nie udało się utworzyć konta.' }
  }
 
  redirect('/check-email')
}
Code
// app/login/login-form.tsx
'use client'
 
import { useActionState } from 'react'
import { login, type AuthState } from './actions'
 
const initialState: AuthState = { error: null }
 
export function LoginForm() {
  const [state, formAction, pending] = useActionState(login, initialState)
 
  return (
    <form action={formAction} className="mx-auto max-w-md space-y-4 p-8">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={pending}>
        {pending ? 'Logowanie…' : 'Zaloguj się'}
      </button>
      {state.error ? <p role="alert">{state.error}</p> : null}
    </form>
  )
}
Code
// app/login/page.tsx
import { LoginForm } from './login-form'
 
export default function LoginPage() {
  return <LoginForm />
}

W prawdziwej aplikacji dodaj walidację Zod, limity prób logowania i sensowny dla potwierdzania adresu e-mail. Przykład powyżej pokazuje przepływ i poprawne przekazanie stanu błędu przez useActionState, ale nie kompletny formularz produkcyjny.

OAuth i callback w Supabase Auth

może zaczynać się po stronie klienta albo serwera. W obu wariantach użytkownik musi zostać przekierowany do dostawcy (providera). Poniżej prosty wariant kliencki:

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.ts
import { 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'
  const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? url.origin)
 
  let redirectUrl = new URL('/dashboard', siteUrl)
 
  try {
    const candidate = new URL(next, siteUrl)
 
    if (
      next.startsWith('/') &&
      !next.startsWith('//') &&
      candidate.origin === siteUrl.origin
    ) {
      redirectUrl = candidate
    }
  } catch {
    // Niepoprawny `next` pozostawia bezpieczny adres domyślny.
  }
 
  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
 
    if (!error) {
      return NextResponse.redirect(redirectUrl)
    }
  }
 
  return NextResponse.redirect(new URL('/login?error=auth', siteUrl))
}

Adres aplikacji użyty w redirectTo musi znajdować się na liście dozwolonych Redirect URLs w Supabase. Sam provider, np. Google, wymaga też skonfigurowania Client ID, Client Secret i callbacku projektu Supabase. Nie przekazuj niesprawdzonego parametru next bezpośrednio do NextResponse.redirect(), bo tworzy to podatność open redirect.

W SSR Supabase korzysta z PKCE. Wymiana code wymaga verifiera zapisanego przy rozpoczęciu flow, dlatego musi odbyć się w tej samej przeglądarce i na tym samym urządzeniu. Dla linków potwierdzających e-mail warto rozważyć osobny endpoint /auth/confirm, który przyjmuje token_hash i wywołuje verifyOtp().

Ochrona stron i danych w Supabase

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.tsx
import { 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.

CRUD z Supabase przez Server Components i Server Actions

Pobieranie danych w Server Component:

Code
// app/dashboard/posts/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function PostsPage() {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  const { data: posts, error } = await supabase
    .from('posts')
    .select('id, title, published, created_at')
    .eq('author_id', user.id)
    .order('created_at', { ascending: false })
 
  if (error) {
    throw new Error(error.message)
  }
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <span>{post.published ? 'Opublikowany' : 'Szkic'}</span>
        </li>
      ))}
    </ul>
  )
}

Mutacja w Server Action:

Code
// 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 w Supabase

RLS w Supabase działa jak automatyczny filtr dodawany do zapytań. Po włączeniu RLS tabela nie zwróci danych przez publiczne , 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.posts
for select
to authenticated
using ((select auth.uid()) = author_id);
 
create policy "Authenticated users can create their own posts"
on public.posts
for insert
to authenticated
with check ((select auth.uid()) = author_id);
 
create policy "Authenticated users can update their own posts"
on public.posts
for update
to authenticated
using ((select auth.uid()) = author_id)
with check ((select auth.uid()) = author_id);
 
create policy "Authenticated users can delete their own posts"
on public.posts
for delete
to authenticated
using ((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.posts
for select
to anon, authenticated
using (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.

Supabase Storage i upload plików w Next.js

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,
  file_size_limit,
  allowed_mime_types
)
values (
  'avatars',
  'avatars',
  true,
  2097152,
  array['image/jpeg', 'image/png', 'image/webp']
)
on conflict (id) do update set
  public = excluded.public,
  file_size_limit = excluded.file_size_limit,
  allowed_mime_types = excluded.allowed_mime_types;
 
create policy "Users can upload their own avatar"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);
 
create policy "Users can update their own avatar"
on storage.objects
for update
to authenticated
using (
  bucket_id = 'avatars'
  and (select auth.uid())::text = (storage.foldername(name))[1]
)
with check (
  bucket_id = 'avatars'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);
 
-- Upsert wymaga również SELECT. Ograniczamy je do własnego katalogu.
create policy "Users can select their own avatar"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'avatars'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);

Bucket jest publiczny, więc pobieranie pliku przez publiczny URL nie wymaga polityki SELECT. Szeroka polityka SELECT dla roli anon pozwoliłaby dodatkowo listować obiekty i ujawniałaby ich nazwy. Ograniczona polityka powyżej jest potrzebna zalogowanemu użytkownikowi do upsert.

Komponent uploadu:

Code
// components/avatar-upload.tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
import { useMemo, useState } from 'react'
 
const MAX_FILE_SIZE = 2 * 1024 * 1024
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const EXTENSION_BY_TYPE: Record<string, string> = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/webp': 'webp',
}
 
export function AvatarUpload({ userId }: { userId: string }) {
  const [uploading, setUploading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const supabase = useMemo(() => createClient(), [])
 
  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0]
    if (!file) return
 
    setError(null)
 
    if (!ALLOWED_TYPES.includes(file.type) || file.size > MAX_FILE_SIZE) {
      setError('Wybierz obraz JPG, PNG albo WebP do 2 MB.')
      return
    }
 
    setUploading(true)
 
    const extension = EXTENSION_BY_TYPE[file.type]
    const filePath = `${userId}/avatar.${extension}`
 
    const { error } = await supabase.storage
      .from('avatars')
      .upload(filePath, file, {
        upsert: true,
        contentType: file.type,
        cacheControl: '3600',
      })
 
    setUploading(false)
 
    if (error) {
      setError('Nie udało się przesłać pliku.')
    }
  }
 
  return (
    <label>
      {uploading ? 'Przesyłanie...' : 'Zmień avatar'}
      <input type="file" accept="image/*" onChange={handleUpload} />
      {error ? <span>{error}</span> : null}
    </label>
  )
}

Walidacja w komponencie poprawia UX, ale nie jest granicą bezpieczeństwa. Granicą są polityki Storage oraz ustawione na buckecie file_size_limit i allowed_mime_types. Pamiętaj jednak, że deklarowany MIME pochodzi od klienta; jeżeli musisz zweryfikować rzeczywisty format lub przeskanować plik, zrób to po stronie zaufanego backendu. Dla wrażliwych plików zamiast publicznego bucketu użyj prywatnego bucketu oraz krótkotrwałych podpisanych URL-i.

Supabase Realtime i subskrypcje na żywo

Supabase udostępnia dwa główne sposoby przesyłania zmian z bazy:

  • Broadcast — rekomendowany dla większej skali i bardziej wymagających systemów; zwykle łączy trigger w Postgresie z prywatnym kanałem,
  • Postgres Changes — prostszy w konfiguracji, dobry dla prototypów i niewielkiej liczby subskrybentów, ale każde zdarzenie musi zostać autoryzowane osobno dla każdego odbiorcy.

Poniższy przykład używa prostszego Postgres Changes. 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.comments
for select
to authenticated
using (
  exists (
    select 1
    from public.posts
    where posts.id = comments.post_id
      and posts.author_id = (select auth.uid())
  )
);

Przykład komponentu:

Code
// components/live-comments.tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
import { postgresChangesFilter } from '@supabase/supabase-js'
import { useEffect, useMemo, useState } from 'react'
 
interface Comment {
  id: string
  post_id: string
  content: string
  author_name: string
  created_at: string
}
 
function mergeComments(current: Comment[], incoming: Comment[]) {
  const byId = new Map(current.map((comment) => [comment.id, comment]))
 
  incoming.forEach((comment) => {
    byId.set(comment.id, comment)
  })
 
  return [...byId.values()].sort((a, b) =>
    a.created_at.localeCompare(b.created_at),
  )
}
 
export function LiveComments({ postId }: { postId: string }) {
  const [comments, setComments] = useState<Comment[]>([])
  const supabase = useMemo(() => createClient(), [])
 
  useEffect(() => {
    let ignore = false
    setComments([])
 
    async function loadComments() {
      const { data } = await supabase
        .from('comments')
        .select('id, post_id, content, author_name, created_at')
        .eq('post_id', postId)
        .order('created_at', { ascending: true })
 
      if (!ignore && data) {
        setComments((current) => mergeComments(current, data))
      }
    }
 
    const channel = supabase
      .channel(`comments:${postId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'comments',
          filter: postgresChangesFilter().eq('post_id', postId),
        },
        (payload) => {
          if (!ignore) {
            setComments((current) =>
              mergeComments(current, [payload.new as Comment]),
            )
          }
        },
      )
      .subscribe((status) => {
        if (!ignore && status === 'SUBSCRIBED') {
          void loadComments()
        }
      })
 
    return () => {
      ignore = true
      void supabase.removeChannel(channel)
    }
  }, [postId, supabase])
 
  return (
    <div>
      {comments.map((comment) => (
        <article key={comment.id}>
          <p>{comment.author_name}</p>
          <p>{comment.content}</p>
        </article>
      ))}
    </div>
  )
}

Początkowe dane są pobierane dopiero po uzyskaniu statusu SUBSCRIBED, a wyniki są scalane po id. Dzięki temu zdarzenie, które nadejdzie w trakcie pierwszego zapytania, nie zostanie pominięte ani dodane dwukrotnie.

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, ale nie jest mechanizmem bezpieczeństwa — dostęp do Postgres Changes egzekwują uprawnienia tabeli i RLS. Przy tysiącach subskrybentów tego samego strumienia rozważ Broadcast, bo Postgres Changes wykonuje kontrolę dostępu osobno dla każdego użytkownika.

Cache i prywatne dane w Supabase + Next.js

Supabase Auth z 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 -em.

Praktyczne reguły:

  • strony panelu użytkownika trzymaj jako dynamiczne,
  • prywatnych danych nie pobieraj w generateStaticParams,
  • po Server Actions używaj revalidatePath(), updateTag() albo revalidateTag(tag, 'max'),
  • nie mieszaj prywatnych danych użytkownika z publicznym ,
  • 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 z Next.js 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 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.
Automatyzacja AI

Często zadawane pytania

Czy Supabase jest dobrym wyborem do Next.js App Router?

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.

Czy nadal używać NEXT_PUBLIC_SUPABASE_ANON_KEY?

W nowych projektach lepiej używać NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. Supabase wspiera jeszcze starsze klucze anon, ale oficjalne materiały kierują nowe projekty w stronę publishable i secret keys. Klucza sb_secret_... ani legacy service_role nigdy nie wolno wysyłać do przeglądarki.

Czy Proxy w Next.js wystarczy do ochrony panelu użytkownika?

Nie jako jedyna warstwa. Mechanizm Proxy jest potrzebny do odświeżania sesji i może robić wstępne redirecty, ale uwierzytelnienie i autoryzację trzeba sprawdzać również w Server Components, Route Handlers i Server Actions. Do ochrony danych po stronie serwera używaj supabase.auth.getClaims(), a getUser() wtedy, gdy potrzebujesz aktualnego rekordu użytkownika.

Czy Realtime działa automatycznie dla każdej tabeli?

Nie. Przy Postgres Changes tabelę trzeba dodać do publikacji supabase_realtime, a dostęp nadal zależy od RLS i uprawnień roli użytkownika. Filtry ograniczają przesyłane zdarzenia, ale nie zastępują autoryzacji. Dla większej skali Supabase rekomenduje Broadcast.

Czy upload plików do Supabase Storage powinien iść przez serwer?

Dla prostych avatarów można uploadować z klienta, jeśli bucket ma poprawne polityki Storage RLS i ścieżka pliku jest powiązana z auth.uid(). Dla większych plików, prywatnych zasobów albo bardziej restrykcyjnej kontroli lepszy jest flow z podpisanym URL-em generowanym po stronie serwera.

O autorze

Maciej Sala

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 rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Backend dla frontendowca: serwer, bazy danych i API

Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej okazuje się, że jakość UI zależy od tego, co dzieje się po drugiej stronie API . Jak backend paginuje dane, jak zwraca błędy, jak trzyma sesję, co robi przy timeoutach i czy potrafi przyjąć większy ruch.

Maciej Sala

Maciej Sala

Founder StriveLab

Route Handlers czy Server Actions? Kiedy co wybrać

App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.

Maciej Sala

Maciej Sala

Founder StriveLab

Jak React Server Components wpływają na SEO i performance?

Przez lata React pchał, coraz więcej i więcej pracy do przeglądarki, w efekcie czego mamy większe bundle, hydratacja trwa dłużej, są puste loadingi i strony, które bez JavaScriptu straciły sens. App Router odwraca ten kierunek. React Server Components pozwalają renderować treść na serwerze bez wysyłania całej logiki do klienta, a Server Actions upraszczają formularze i mutacje. To ma znaczenie dla SEO, bo crawler szybciej dostaje HTML. Ma też znaczenie dla biznesu, bo użytkownik szybciej widzi i dostaje to, po co przyszedł.

Maciej Sala

Maciej Sala

Founder StriveLab