yigityalim/x/github/işe al/paylaş
El Kitaplarına Dön

Supabase Üretim Rehberi

Supabase için opinionated bir üretim referansı — istemci kurulumu, auth akışları, RLS tasarımı, şema kalıpları, indeksleme, Edge Functions, Realtime, Storage ve migration stratejisi. Gerçek projelerden. Tutorial değil.

Son güncelleme: 2026-04-20

Teknoloji Yığını

SupabasePostgreSQLNext.jsTypeScripttRPC

Bağlantılar

GitHub
SonrakiTurborepo ile Monorepo Mimarisi
© 2026 Yiğit Yalım. Tüm hakları saklıdır.
/

Bu referans, beş üretim sistemi üzerinden Supabase çalıştırarak oluşturuldu — 600K LOC'luk bir AI işe alım platformu, çok kiracılı bir fuar SaaS'ı, gerçek zamanlı bir manevi pazar yeri, bir salon yönetim sistemi ve otonom bir otomat makine backend'i.

Her bölüm tek bir soruyu yanıtlar: Üretimde gerçekten ne önemli?

@supabase/ssr ile yeni asimetrik JWT anahtarı modelini (Mayıs 2025'ten itibaren varsayılan) ve yeni API anahtarı formatını (sb_publishable_xxx / sb_secret_xxx) kapsar. Eski anon ve service_role anahtarları geçiş süreci boyunca çalışmaya devam eder.


İçindekiler

  1. İstemci Kurulumu
  2. Kimlik Doğrulama
  3. Satır Düzeyi Güvenlik
  4. Şema Tasarımı
  5. İndeksleme
  6. Edge Functions
  7. Realtime
  8. Storage
  9. Migration'lar
  10. Performans ve İzleme
  11. Güvenlik Kontrol Listesi

1. İstemci Kurulumu

İki İstemci, İki Bağlam

@supabase/ssr ile her zaman iki istemcin olur:

createBrowserClient  → Client Components, tarayıcı
createServerClient   → Server Components, Server Actions, Route Handler'lar, Proxy

Birini diğerinin yerine asla kullanma. Sunucu istemcisi çerezleri okur ve yazar; tarayıcı istemcisi localStorage kullanır.

Kurulum

bun add @supabase/supabase-js @supabase/ssr

Ortam Değişkenleri

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
 
# Yeni anahtar formatı (tercih edilen — Mayıs 2025+)
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx
 
# Eski anahtarlar (geçiş süreci boyunca çalışır)
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbG...

service_role / sb_secret_xxx anahtarlarını asla NEXT_PUBLIC_ ile başlatma — tarayıcıya gönderilir.

Tarayıcı İstemcisi

// lib/supabase/client.ts
import 'client-only'
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_PUBLISHABLE_KEY!
  )
}

Sunucu İstemcisi

// lib/supabase/server.ts
import 'server-only'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/types/supabase'
 
export async function createClient() {
  const cerezler = await cookies()
 
  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll: () => cerezler.getAll(),
        setAll: (ayarlanacakCerezler) => {
          try {
            ayarlanacakCerezler.forEach(({ name, value, options }) =>
              cerezler.set(name, value, options)
            )
          } catch {
            // Server Component'ler çerez yazamaz — yoksay
            // Proxy gerçek çerez yenilemeyi halleder
          }
        },
      },
    }
  )
}

Proxy İstemcisi (eskiden Middleware)

Proxy istemcisi özeldir — hem istekten çerez okur hem de yanıta çerez yazar. Token yenileme bu şekilde yayılır.

// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import type { Database } from '@/types/supabase'
 
export async function oturumGuncelle(request: NextRequest) {
  let supabaseYaniti = NextResponse.next({ request })
 
  const supabase = createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (ayarlanacakCerezler) => {
          // Hem isteğe hem yanıta yaz
          ayarlanacakCerezler.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseYaniti = NextResponse.next({ request })
          ayarlanacakCerezler.forEach(({ name, value, options }) =>
            supabaseYaniti.cookies.set(name, value, options)
          )
        },
      },
    }
  )
 
  // Süresi dolmuş token'ı yenile — bunu kaldırma
  // Navigasyonlar arasında oturumun canlı kalmasını sağlayan bu
  await supabase.auth.getClaims()
 
  return supabaseYaniti
}
 
export async function proxy(request: NextRequest) {
  return await oturumGuncelle(request)
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}

Tip Üretimi

supabase gen types typescript --project-id your-project-id > types/supabase.ts

package.json'a ekle:

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

2. Kimlik Doğrulama

getClaims vs getUser vs getSession

Alacağın en önemli API kararı bu. Üç metodun temelden farklı davranışları var:

MetodAğ çağrısıGüven seviyesiKullanım durumu
getSession()YokGüvenilmez — çerezler sahte olabilirSunucu tarafında asla kullanma
getClaims()Nadiren (önbelleğe alınmış JWKs)JWT-doğrulanmış, DB-doğrulanmamışÇoğu sunucu okuma
getUser()Her zamanDB-doğrulanmış, yetkiliGüvenlik açısından kritik kontroller
// ❌ getSession()'ı sunucu tarafında asla kullanma
const { data: { session } } = await supabase.auth.getSession()
// session.user çerezlerden geliyor — sahte olabilir
 
// ✅ Çoğu sunucu tarafı auth kontrolü için getClaims() kullan
// Asimetrik anahtarlarla: JWKs üzerinden yerel JWT doğrulaması (önbelleğe alınmış, hızlı)
// Simetrik anahtarlarla: hâlâ Auth sunucusuna istek atar (getUser() ile aynı)
const { data, error } = await supabase.auth.getClaims()
if (error || !data) redirect('/giris')
const kullaniciId = data.claims.sub
 
// ✅ Garantili tazelik gerektiğinde getUser() kullan
// Her zaman Auth sunucusuna gider — oturumun iptal edilmediğini doğrular
const { data: { user }, error } = await supabase.auth.getUser()
// Kullanım: çıkış tespiti, yasaklı kullanıcılar, şifre değişiklikleri

getClaims() içindeki güvenlik boşluğu: JWT imzasını ve süresini doğrular ama token verildikten sonra kullanıcının yasaklandığını, silindiğini veya sunucu tarafında oturumunun kapatıldığını kontrol etmez. Uygulamanın anında iptal gerektirmesi gerekiyorsa getUser() kullan veya JWT süresini kısa tut (varsayılan 1 saattir).

İstek Başına Auth Önbellekleme

Aynı sayfadaki birden fazla Server Component'in her biri bağımsız olarak getClaims() çağırmamalı:

// lib/auth.ts
import 'server-only'
import { cache } from 'react'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
// İstek başına memoize edilmiş — tüm bileşenler sonucu paylaşır
export const onbelleklenmisKullanici = cache(async () => {
  const supabase = await createClient()
  const { data, error } = await supabase.auth.getClaims()
  if (error || !data) return null
  return data.claims
})
 
export const authGerekli = cache(async () => {
  const claims = await onbelleklenmisKullanici()
  if (!claims) redirect('/giris')
  return claims
})
// app/panel/page.tsx
import { authGerekli } from '@/lib/auth'
 
export default async function PanelSayfasi() {
  const claims = await authGerekli() // kimlik doğrulanmamışsa yönlendirir
  // claims.sub = kullanıcı ID
  // claims.app_metadata = roller, izinler
  return <Panel kullaniciId={claims.sub} />
}

Auth Akışları

E-posta + Şifre:

// Kayıt
const { data, error } = await supabase.auth.signUp({
  email: 'kullanici@ornek.com',
  password: 'guvenli-sifre',
  options: {
    emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
    data: { full_name: 'Kullanıcı Adı' }, // raw_user_meta_data'ya gider
  },
})
 
// Giriş
const { data, error } = await supabase.auth.signInWithPassword({
  email: 'kullanici@ornek.com',
  password: 'guvenli-sifre',
})

OAuth:

const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
    scopes: 'email profile',
  },
})

Auth Callback Route Handler:

// app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
 
export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const sonraki = searchParams.get('next') ?? '/panel'
 
  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) {
      return NextResponse.redirect(`${origin}${sonraki}`)
    }
  }
 
  return NextResponse.redirect(`${origin}/auth/hata`)
}

user_metadata vs app_metadata

Supabase'deki 1 numaralı güvenlik tuzağı:

user_metadata (raw_user_meta_data)
  ├── Kullanıcı tarafından düzenlenebilir (supabase.auth.updateUser() ile)
  ├── JWT'de user_metadata olarak görünür
  └── Yetkilendirme kararları için ASLA kullanma

app_metadata (raw_app_meta_data)
  ├── Yalnızca service_role / Admin API ile yazılabilir
  ├── JWT'de app_metadata olarak görünür
  └── Yetkilendirme kararları için GÜVENLİ
// ❌ user_metadata'yı auth kararlarında kullanma
const rol = claims.user_metadata?.role // kullanıcı bunu 'admin' olarak ayarlayabilir!
 
// ✅ Roller için app_metadata kullan
const rol = claims.app_metadata?.role // yalnızca backend'in yazabildiği
 
// app_metadata ayarlamak Admin API gerektirir (service_role)
const { error } = await supabaseAdmin.auth.admin.updateUserById(kullaniciId, {
  app_metadata: { role: 'admin' },
})

Çıkış Yapma

// Server Action
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export async function cikisYap() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/giris')
}

3. Satır Düzeyi Güvenlik

Public Şemasındaki Her Tabloda RLS'i Etkinleştir

-- RLS'i etkinleştir
alter table public.siparisler enable row level security;
 
-- Tablo sahipleri için bile RLS'i zorla (güvenlik açısından kritik)
alter table public.siparisler force row level security;

public şemasındaki tablolar PostgREST (otomatik oluşturulan REST API) üzerinden erişilebilir. RLS olmadan, yayınlanabilir anahtarınla herkes tüm satırları okuyabilir.

Temel Policy Kalıpları

-- Kullanıcılar yalnızca kendi satırlarını görür
create policy "kullanicilar_kendi_satirlari" on public.siparisler
  for all
  to authenticated
  using (user_id = auth.uid());
 
-- Ayrı select/insert/update policy'leri — ayrıntılı kontrol için
create policy "kullanicilar_secebilir" on public.siparisler
  for select
  to authenticated
  using (user_id = auth.uid());
 
create policy "kullanicilar_ekleyebilir" on public.siparisler
  for insert
  to authenticated
  with check (user_id = auth.uid()); -- yazma için with check
 
create policy "kullanicilar_kendi_gunceller" on public.siparisler
  for update
  to authenticated
  using (user_id = auth.uid())      -- satır kullanıcıya ait olmalı
  with check (user_id = auth.uid()); -- güncellenen satır hâlâ kullanıcıya ait olmalı
 
-- Herkese açık okuma, kimlik doğrulanmış yazma
create policy "acik_okuma" on public.yazilar
  for select using (true);
 
create policy "auth_yazma" on public.yazilar
  for insert to authenticated
  with check (yazar_id = auth.uid());

Çok Kiracılı RLS

-- Her kullanıcı bir kiracıya ait
create table public.kiracı_uyeler (
  kiracı_id uuid not null references public.kiracılar(id),
  user_id   uuid not null references auth.users(id),
  rol       text not null default 'uye',
  primary key (kiracı_id, user_id)
);
 
-- Yardımcı fonksiyon — SECURITY DEFINER olarak çalışır, RLS'i atlar
create or replace function public.kullanici_kiracı_idlerini_al()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
  select kiracı_id
  from public.kiracı_uyeler
  where user_id = (select auth.uid())
$$;
 
-- Policy: kullanıcılar ait oldukları herhangi bir kiracının satırlarını görür
create policy "kiracı_veri_erisimi" on public.siparisler
  for all
  to authenticated
  using (kiracı_id in (select public.kullanici_kiracı_idlerini_al()));

Rol Tabanlı Erişim Kontrolü (RBAC)

JWT tabanlı policy'ler için rolleri app_metadata'da sakla:

-- Admin API ile rol ayarla (yalnızca sunucu tarafı)
-- supabase.auth.admin.updateUserById(id, { app_metadata: { role: 'admin' } })
 
-- JWT tabanlı rol policy'si
create policy "admin_tam_erisim" on public.siparisler
  for all
  to authenticated
  using (
    (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
  );
 
-- Birleşik: adminler hepsini görür, kullanıcılar kendininkini
create policy "siparis_erisimi" on public.siparisler
  for select
  to authenticated
  using (
    user_id = auth.uid()
    or (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
  );

RLS Performansı — Kritik Kalıp

En yaygın RLS performans hatası, fonksiyonları önbelleksiz çağırmak:

-- ❌ auth.uid() HER satır için çağrılır
create policy "yavas_policy" on public.siparisler
  using (user_id = auth.uid());
 
-- ✅ SELECT içine sar — bir kez değerlendirilir, sonuç önbelleğe alınır
create policy "hizli_policy" on public.siparisler
  using (user_id = (select auth.uid()));
 
-- ❌ Alt sorgu satır başına çalışır
create policy "yavas_takim_policy" on public.siparisler
  using (
    takim_id in (
      select takim_id from public.takim_uyeler where user_id = auth.uid()
    )
  );
 
-- ✅ Security definer fonksiyon kullan — bir kez çalışır
create policy "hizli_takim_policy" on public.siparisler
  using (takim_id in (select public.kullanici_takim_idlerini_al()));

RLS policy'lerinde kullanılan sütunları her zaman indeksle:

create index siparisler_user_id_idx on public.siparisler (user_id);
create index siparisler_kiracı_id_idx on public.siparisler (kiracı_id);
create index takim_uyeler_user_id_idx on public.takim_uyeler (user_id);

View'lar RLS'i Atlar

-- ❌ Bu view TÜM satırları açığa çıkarır — RLS'i yoksayar
create view siparis_ozetleri as
  select id, user_id, toplam from public.siparisler;
 
-- ✅ Postgres 15+: security_invoker view'ı RLS'e uymaya zorlar
create view siparis_ozetleri
  with (security_invoker = true) as
  select id, user_id, toplam from public.siparisler;
 
-- Eski Postgres için: doğrudan erişimi iptal et
revoke all on siparis_ozetleri from anon, authenticated;

UPDATE SELECT Policy Gerektirir

Bu sessiz bir başarısızlıktır — hata yok, satırlar sadece güncellenmez:

-- ❌ Yalnızca UPDATE policy — güncellemeler sessizce 0 satır döner
create policy "kendi_guncelle" on public.siparisler
  for update
  using (user_id = auth.uid());
 
-- ✅ UPDATE'in çalışması için SELECT policy de olmalı
create policy "kendi_sec" on public.siparisler
  for select
  using (user_id = auth.uid());
 
create policy "kendi_guncelle" on public.siparisler
  for update
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

Service Role RLS'i Atlar

// RLS'i atlaması gereken admin işlemleri için
import { createClient } from '@supabase/supabase-js'
 
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // Asla NEXT_PUBLIC_ içinde olmayacak
  { auth: { autoRefreshToken: false, persistSession: false } }
)
 
// TÜM RLS policy'lerini atlar — dikkatli kullan
const { data } = await supabaseAdmin
  .from('siparisler')
  .select('*')
  .eq('user_id', hedefKullaniciId)

4. Şema Tasarımı

Birincil Anahtarlar

-- ✅ Sıralı: bigint identity (tek DB, en iyi performans)
create table public.siparisler (
  id bigint generated always as identity primary key
);
 
-- ✅ Dağıtık/açık ID'ler: UUIDv7 (zamana dayalı sıra, parçalanma yok)
-- pg_uuidv7 extension gerektirir
create extension if not exists pg_uuidv7;
create table public.olaylar (
  id uuid default uuid_generate_v7() primary key
);
 
-- ❌ Kaçın: büyük tablolarda rastgele UUID v4 PK (indeks parçalanması)
create table public.kotu (
  id uuid default gen_random_uuid() primary key -- yazma yüklenmesine neden olur
);

Veri Tipleri

-- ✅ Doğru tipler
create table public.kullanicilar (
  id          bigint generated always as identity primary key,
  eposta      text not null unique,               -- varchar(255) değil, text
  olusturuldu timestamptz not null default now(), -- her zaman timestamptz
  aktif_mi    boolean not null default true,      -- varchar değil
  fiyat       numeric(10, 2),                     -- float değil (hassasiyet önemli)
  meta_veri   jsonb                               -- json değil (indekslenebilir, hızlı)
);
 
-- Neden varchar yerine text?
-- text ve varchar Postgres'te özdeş performansa sahip
-- varchar(n) kısıtlama ekler ama depolama tasarrufu sağlamaz
-- Gerçekten DB seviyesinde uzunluk kısıtı gerekmiyorsa text kullan

Yabancı Anahtarlar + İndeksler

Postgres, yabancı anahtar sütunlarını otomatik indekslemez:

create table public.siparisler (
  id          bigint generated always as identity primary key,
  user_id     uuid not null references auth.users(id) on delete cascade,
  kiracı_id   uuid not null references public.kiracılar(id),
  durum       text not null default 'bekliyor',
  olusturuldu timestamptz not null default now()
);
 
-- FK sütunlarını her zaman manuel indeksle
create index siparisler_user_id_idx    on public.siparisler (user_id);
create index siparisler_kiracı_id_idx  on public.siparisler (kiracı_id);
 
-- Eksik tüm FK indekslerini bul
select
  conrelid::regclass as tablo_adi,
  a.attname as fk_sutunu
from pg_constraint c
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = any(c.conkey)
where c.contype = 'f'
  and not exists (
    select 1 from pg_index i
    where i.indrelid = c.conrelid and a.attnum = any(i.indkey)
  );

İsimlendirme Kuralları

-- ✅ küçük harf snake_case — tırnak işareti olmadan her yerde çalışır
create table public.kullanici_profilleri (
  user_id    uuid,
  ad         text,
  soyad      text
);
 
-- ❌ camelCase veya PascalCase — sonsuza kadar tırnak gerektir
create table public."KullaniciProfilleri" ( -- her zaman tırnak gerekir
  "userId"    uuid,
  "firstName" text
);
 
-- İpucu: ORM'den gelen mixed-case ile karşılaşırsan, uyumluluk katmanı olarak view oluştur
create view kullanici_profilleri as
  select "userId" as user_id, "firstName" as ad from "KullaniciProfilleri";

Kısıtlamaları Güvenli Ekleme

PostgreSQL ADD CONSTRAINT IF NOT EXISTS desteklemez:

-- ❌ Sözdizimi hatası
alter table public.profiller
  add constraint if not exists profiller_eposta_unique unique (eposta);
 
-- ✅ İdempotent kısıtlama oluşturma için DO bloğu
do $$
begin
  if not exists (
    select 1 from pg_constraint
    where conname = 'profiller_eposta_unique'
    and conrelid = 'public.profiller'::regclass
  ) then
    alter table public.profiller
      add constraint profiller_eposta_unique unique (eposta);
  end if;
end $$;

Büyük Tabloları Bölme

-- 100M+ satır veya zaman serisi verisi için
create table public.olaylar (
  id         bigint generated always as identity,
  olusturuldu timestamptz not null,
  veri       jsonb
) partition by range (olusturuldu);
 
create table public.olaylar_2025_01 partition of public.olaylar
  for values from ('2025-01-01') to ('2025-02-01');
 
create table public.olaylar_2025_02 partition of public.olaylar
  for values from ('2025-02-01') to ('2025-03-01');
 
-- Eski veriyi anında sil (saatler süren DELETE yerine)
drop table public.olaylar_2024_01;

profiles Tablo Kalıbı

auth.users'ı genişletmek için standart kalıp:

create table public.profiller (
  id         uuid primary key references auth.users(id) on delete cascade,
  tam_ad     text,
  avatar_url text,
  guncellendi timestamptz default now()
);
 
alter table public.profiller enable row level security;
 
create policy "profiller_kendi_sec" on public.profiller
  for select to authenticated
  using (id = (select auth.uid()));
 
create policy "profiller_kendi_guncelle" on public.profiller
  for update to authenticated
  using (id = (select auth.uid()))
  with check (id = (select auth.uid()));
 
-- Kayıtta otomatik profil oluştur
create or replace function public.yeni_kullanici_isle()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  insert into public.profiller (id, tam_ad)
  values (new.id, new.raw_user_meta_data ->> 'full_name');
  return new;
end;
$$;
 
create trigger auth_kullanici_olusturuldu
  after insert on auth.users
  for each row execute procedure public.yeni_kullanici_isle();

5. İndeksleme

İndeks Türleri

-- B-tree (varsayılan): =, <, >, BETWEEN, IN, LIKE 'önek%', IS NULL
create index siparisler_olusturuldu_idx on public.siparisler (olusturuldu);
 
-- GIN: diziler, JSONB içerme (@>), tam metin arama
create index urunler_ozellikler_gin_idx on public.urunler using gin (ozellikler);
create index yazilar_icerik_fts_idx on public.yazilar using gin (to_tsvector('turkish', icerik));
 
-- GiST: geometrik veri, aralık türleri, en yakın komşu (KNN)
create index konumlar_nokta_idx on public.yerler using gist (konum);
 
-- BRIN: çok büyük zaman serisi tablolar (B-tree'den 10-100x küçük)
-- Yalnızca veri sütuna göre fiziksel olarak sıralıyken faydalı
create index olaylar_zaman_brin_idx on public.olaylar using brin (olusturuldu);
 
-- Hash: yalnızca eşitlik (= operatörü), eşitlik için B-tree'den biraz hızlı
create index oturumlar_token_hash_idx on public.oturumlar using hash (token);

Bileşik İndeksler

Sütun sırası önemli. Önce eşitlik sütunları, sonra aralık sütunları:

-- Sorgu: WHERE durum = 'bekliyor' AND olusturuldu > '2025-01-01'
-- ✅ Doğru sıra: eşitlik aralıktan önce
create index siparisler_durum_olusturuldu_idx on public.siparisler (durum, olusturuldu);
 
-- Bu indeks şunları yanıtlayabilir:
-- WHERE durum = 'bekliyor'
-- WHERE durum = 'bekliyor' AND olusturuldu > '2025-01-01'
 
-- Bu indeks verimli yanıtlayamaz:
-- WHERE olusturuldu > '2025-01-01' (en sol önek yok)

Kapsayan İndeksler

Sık seçilen sütunları dahil ederek heap erişimini önle:

-- Normal: indeks taraması satır ID'sini bulur, sonra heap'ten müşteri_id, toplam alır
create index siparisler_durum_idx on public.siparisler (durum);
select durum, musteri_id, toplam from public.siparisler where durum = 'gonderildi';
 
-- Kapsayan: tüm sütunlar indeksten gelir, heap erişimi yok
create index siparisler_durum_kapsayan_idx
  on public.siparisler (durum)
  include (musteri_id, toplam);

Kısmi İndeksler

Yalnızca gerçekten sorguladığın satırları indeksle:

-- Yalnızca aktif kullanıcıları indeksle (sorguların %90'ı deleted_at is null filtreler)
create index kullanicilar_aktif_eposta_idx on public.kullanicilar (eposta)
  where deleted_at is null;
 
-- Yalnızca bekleyen siparişler (tamamlananlar nadiren sorgulanır)
create index siparisler_bekliyor_idx on public.siparisler (olusturuldu)
  where durum = 'bekliyor';
 
-- Yalnızca null olmayan SKU'lar
create index urunler_sku_idx on public.urunler (sku)
  where sku is not null;

6. Edge Functions

Edge Function Ne Zaman Kullanılır

Edge Functions Deno üzerinde edge'de çalışır. Şunlar için kullan:

✅ Webhook'lar (Stripe, GitHub, SendGrid) — HMAC imza doğrulaması gerekir
✅ API anahtarlarını gizlemenin gerektiği üçüncü taraf API çağrıları
✅ Özel auth akışları (sihirli linkler, özel OAuth)
✅ Görüntü/dosya işleme
✅ Resend/Postmark ile e-posta gönderme
✅ Next.js sunucunda istemediğin ağır hesaplamalar

❌ Basit CRUD — PostgREST (otomatik oluşturulan API) ile doğrudan yapabilirsin
❌ Server Action olabilecek iş mantığı — Next.js kullan
❌ Uzun süren işler (> 150s zaman aşımı) — pg_cron veya harici kuyruklar kullan

Temel Yapı

// supabase/functions/eposta-gonder/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
 
Deno.serve(async (req: Request) => {
  // Auth kontrolü — JWT'yi her zaman doğrula
  const authBasligi = req.headers.get('Authorization')
  if (!authBasligi) {
    return new Response('Yetkisiz', { status: 401 })
  }
 
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!, // anon anahtar + kullanıcı JWT kullan
    {
      global: { headers: { Authorization: authBasligi } },
    }
  )
 
  // Kullanıcıyı doğrula
  const { data: { user }, error } = await supabase.auth.getUser()
  if (error || !user) {
    return new Response('Yetkisiz', { status: 401 })
  }
 
  const govde = await req.json()
 
  // İşin burada
  const sonuc = await epostaGonder(govde.alici, govde.konu, govde.html)
 
  return new Response(JSON.stringify({ basarili: true }), {
    headers: { 'Content-Type': 'application/json' },
  })
})

Webhook İşleyici Kalıbı

// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@14'
 
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
 
Deno.serve(async (req: Request) => {
  const imza = req.headers.get('stripe-signature')
  const govde = await req.text()
 
  let olay: Stripe.Event
  try {
    olay = stripe.webhooks.constructEvent(
      govde,
      imza!,
      Deno.env.get('STRIPE_WEBHOOK_SECRET')!
    )
  } catch {
    return new Response('Geçersiz imza', { status: 400 })
  }
 
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // admin işlemler için service role
  )
 
  switch (olay.type) {
    case 'checkout.session.completed': {
      const oturum = olay.data.object as Stripe.CheckoutSession
      await supabase
        .from('abonelikler')
        .upsert({
          user_id: oturum.metadata?.user_id,
          stripe_oturum_id: oturum.id,
          durum: 'aktif',
        })
      break
    }
    case 'customer.subscription.deleted': {
      // iptal işlemini yönet
      break
    }
  }
 
  return new Response(JSON.stringify({ alindi: true }), {
    headers: { 'Content-Type': 'application/json' },
  })
})

Next.js'ten Edge Function Çağırma

// Server Action veya Server Component'ten
const { data, error } = await supabase.functions.invoke('eposta-gonder', {
  body: { alici: 'kullanici@ornek.com', konu: 'Merhaba', html: '<p>Selam</p>' },
})
 
// Client Component'ten
const supabase = createClient()
const { data, error } = await supabase.functions.invoke('benim-fonksiyonum', {
  body: { param: 'deger' },
})

Yerel Geliştirme

# Tüm fonksiyonları yerel olarak sun
supabase functions serve
 
# Belirli bir fonksiyonu env ile sun
supabase functions serve eposta-gonder --env-file .env.local
 
# Deploy et
supabase functions deploy eposta-gonder

7. Realtime

Realtime Ne Zaman Kullanılır

✅ Canlı işbirliği özellikleri (varlık, imleçler)
✅ Sohbet / mesajlaşma
✅ Yenileme olmadan güncellenen panel metrikleri
✅ Bildirim iletimi
✅ Sipariş durumu takibi

❌ 30 saniyede bir değişen veriler — polling yeterli
❌ Her tablo — yalnızca gerekli yerde etkinleştir
❌ Yüksek hacimli tablolar — Postgres Changes yerine Broadcast kullan

Postgres Changes

Tabloda INSERT, UPDATE, DELETE dinle:

'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
 
export function SiparisTakip({ siparisId }: { siparisId: string }) {
  const [durum, setDurum] = useState<string>('bekliyor')
  const supabase = createClient()
 
  useEffect(() => {
    const kanal = supabase
      .channel(`siparis-${siparisId}`)
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'siparisler',
          filter: `id=eq.${siparisId}`, // her zaman filtrele — tüm satırları dinleme
        },
        (payload) => {
          setDurum(payload.new.durum)
        }
      )
      .subscribe()
 
    return () => {
      supabase.removeChannel(kanal)
    }
  }, [siparisId, supabase])
 
  return <div>Durum: {durum}</div>
}

RLS, Realtime için de geçerlidir. Bir değişiklik yalnızca o abonenin RLS policy'lerinin o satırı SELECT etmesine izin verdiği durumlarda yayınlanır.

Tabloda Realtime'ı etkinleştir:

-- Supabase dashboard: Database → Replication → Tables
-- Veya SQL ile:
alter publication supabase_realtime add table public.siparisler;

Broadcast

Veritabanı kalıcılığı gerektirmeyen yüksek hacimli olaylar için:

// Gönderen
const kanal = supabase.channel('oda-1')
await kanal.subscribe()
kanal.send({
  type: 'broadcast',
  event: 'imleç-hareket',
  payload: { x: 100, y: 200, kullaniciId: 'abc' },
})
 
// Alan
supabase
  .channel('oda-1')
  .on('broadcast', { event: 'imleç-hareket' }, (payload) => {
    imlecGuncelle(payload.payload)
  })
  .subscribe()

Presence

Kanalda kimlerin çevrimiçi olduğunu takip et:

const kanal = supabase.channel('oda-1', {
  config: { presence: { key: kullaniciId } },
})
 
// Mevcut kullanıcıyı takip et
await kanal.track({ kullaniciId, ad: 'Yiğit', imleç: { x: 0, y: 0 } })
 
// Presence değişikliklerini dinle
kanal
  .on('presence', { event: 'sync' }, () => {
    const durum = kanal.presenceState()
    // { kullaniciId: [{ kullaniciId, ad, imleç }] }
  })
  .on('presence', { event: 'join' }, ({ key, newPresences }) => {
    console.log('Kullanıcı katıldı:', key)
  })
  .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
    console.log('Kullanıcı ayrıldı:', key)
  })
  .subscribe()

Temizlik

Bileşenler unmount olduğunda kanalları her zaman kaldır:

useEffect(() => {
  const kanal = supabase.channel('benim-kanalim')
    .on(...)
    .subscribe()
 
  return () => {
    supabase.removeChannel(kanal)
  }
}, [])

8. Storage

Bucket Kurulumu

-- SQL ile bucket oluştur
insert into storage.buckets (id, name, public)
values ('avatarlar', 'avatarlar', true); -- public = herkese açık URL'ler
 
insert into storage.buckets (id, name, public)
values ('belgeler', 'belgeler', false); -- private = imzalı URL gerekli

Veya dashboard üzerinden: Storage → Create Bucket.

Storage RLS Policy'leri

Storage, aynı RLS sistemini kullanır ama storage.objects üzerinde:

-- Kullanıcılar kendi klasörlerine yükleyebilir
create policy "kullanicilar_kendi_yukler" on storage.objects
  for insert to authenticated
  with check (
    bucket_id = 'avatarlar'
    and (storage.foldername(name))[1] = auth.uid()::text
  );
 
-- Kullanıcılar kendi dosyalarını okuyabilir
create policy "kullanicilar_kendi_okur" on storage.objects
  for select to authenticated
  using (
    bucket_id = 'belgeler'
    and (storage.foldername(name))[1] = auth.uid()::text
  );
 
-- Public bucket için herkese açık okuma
create policy "acik_okuma" on storage.objects
  for select using (bucket_id = 'avatarlar');
 
-- ÖNEMLİ: Upsert (dosya değiştirme) INSERT + SELECT + UPDATE gerektirir
create policy "kullanicilar_kendi_upsert" on storage.objects
  for update to authenticated
  using ((storage.foldername(name))[1] = auth.uid()::text)
  with check ((storage.foldername(name))[1] = auth.uid()::text);

Dosya Yükleme

// Server Action'dan yükleme
const { data, error } = await supabase.storage
  .from('avatarlar')
  .upload(`${kullaniciId}/avatar.jpg`, dosya, {
    cacheControl: '3600',
    upsert: true,
  })
 
// Public URL al (public bucket'lar için)
const { data: { publicUrl } } = supabase.storage
  .from('avatarlar')
  .getPublicUrl(`${kullaniciId}/avatar.jpg`)
 
// İmzalı URL al (private bucket'lar için)
const { data: { signedUrl }, error } = await supabase.storage
  .from('belgeler')
  .createSignedUrl(`${kullaniciId}/sozlesme.pdf`, 3600) // 1 saatte sona erer

Dosya Yolu Kuralları

avatarlar/{user_id}/avatar.jpg      — kullanıcı kapsamlı
belgeler/{user_id}/{dosyaadi}       — kullanıcı kapsamlı
kiracı/{kiracı_id}/dosyalar/{ad}    — kiracı kapsamlı
acik/logolar/{ad}                   — paylaşımlı genel varlıklar

9. Migration'lar

Yerel Geliştirme İş Akışı

# Yerel Supabase'i başlat
supabase start
 
# Yeni migration dosyası oluştur
supabase migration new siparisler_tablosu_ekle
 
# Şema üzerinde yinele — execute_sql veya psql kullan, apply_migration değil
# apply_migration her çağrıda geçmiş yazar, diff'i imkansız kılar
 
# Şema hazır olduğunda diff'ten migration üret
supabase db pull --local --yes
 
# Migration'ı uygula
supabase db push --local
 
# Migration listesini kontrol et
supabase migration list

Migration Dosya Yapısı

-- supabase/migrations/20250420120000_siparisler_tablosu_ekle.sql
 
-- Yıkıcı işlemleri her zaman transaction içine sar
begin;
 
create table public.siparisler (
  id          bigint generated always as identity primary key,
  user_id     uuid not null references auth.users(id) on delete cascade,
  durum       text not null default 'bekliyor',
  toplam      numeric(10, 2) not null,
  olusturuldu timestamptz not null default now(),
  guncellendi timestamptz not null default now()
);
 
create index siparisler_user_id_idx     on public.siparisler (user_id);
create index siparisler_durum_idx       on public.siparisler (durum);
create index siparisler_olusturuldu_idx on public.siparisler (olusturuldu desc);
 
alter table public.siparisler enable row level security;
 
create policy "siparisler_kullanici_policy" on public.siparisler
  for all to authenticated
  using (user_id = (select auth.uid()))
  with check (user_id = (select auth.uid()));
 
commit;

Güvenli Şema Değişiklikleri

-- ✅ Güvenli: nullable sütun ekleme (tablo yeniden yazma yok)
alter table public.kullanicilar add column biyografi text;
 
-- ✅ Güvenli: varsayılan değerli sütun ekleme (Postgres 11+)
alter table public.kullanicilar add column premium_mi boolean not null default false;
 
-- ⚠️ Dikkatli: mevcut sütuna NOT NULL ekleme
-- Önce nullable ekle, doldurandır, sonra kısıtlamayı ekle
alter table public.kullanicilar add column telefon text;
update public.kullanicilar set telefon = '' where telefon is null;
alter table public.kullanicilar alter column telefon set not null;
 
-- ⚠️ Dikkatli: büyük tabloya indeks ekleme
-- Tablo kilidini önlemek için CONCURRENTLY kullan
create index concurrently siparisler_kiracı_id_idx on public.siparisler (kiracı_id);
 
-- ❌ Tehlikeli: tüm kullanımları kontrol etmeden sütun düşürme
-- Önce kontrol et: grep -r 'eski_sutun_adi' kod-tabanin
alter table public.kullanicilar drop column eski_sutun;

Merge Etmeden Önce Advisor'ları Çalıştır

# Güvenlik ve performans sorunlarını kontrol et
supabase db advisors
 
# Veya MCP ile
# mcp: get_advisors

10. Performans ve İzleme

EXPLAIN ANALYZE

-- Her zaman buffers kullan — önbellek isabet oranını gösterir
explain (analyze, buffers, format text)
select * from public.siparisler
where user_id = 'abc' and durum = 'bekliyor';
 
-- Dikkat edilecek başlıca şeyler:
-- "Seq Scan" büyük tabloda → eksik indeks
-- "Rows Removed by Filter: 999000" → düşük seçicilik
-- "Buffers: read >> hit" → veri önbellekte değil
-- Sort'ta "external merge" → work_mem'i artır
-- "Nested Loop with loops=10000" → farklı join stratejisi düşün

pg_stat_statements

-- Etkinleştir (Supabase'de genellikle zaten etkin)
create extension if not exists pg_stat_statements;
 
-- Toplam süreye göre en yavaş sorgular
select
  calls,
  round(total_exec_time::numeric, 2) as toplam_ms,
  round(mean_exec_time::numeric, 2) as ortalama_ms,
  round(stddev_exec_time::numeric, 2) as sapma_ms,
  query
from pg_stat_statements
order by total_exec_time desc
limit 20;
 
-- Ortalama > 100ms olan sorgular
select query, mean_exec_time, calls
from pg_stat_statements
where mean_exec_time > 100
order by mean_exec_time desc;
 
-- Optimizasyon sonrası temiz taban çizgisi için sıfırla
select pg_stat_statements_reset();

VACUUM ve İstatistikler

-- Tabloların en son ne zaman analiz edildiğini kontrol et
select
  relname,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze,
  n_live_tup,
  n_dead_tup
from pg_stat_user_tables
order by n_dead_tup desc;
 
-- Büyük veri yüklemelerinden sonra manuel analiz
analyze public.siparisler;
 
-- Yoğun tablolar için autovacuum ayarlama
alter table public.siparisler set (
  autovacuum_vacuum_scale_factor = 0.05,  -- %5 ölü tuplelda vacuum (varsayılan %20)
  autovacuum_analyze_scale_factor = 0.02  -- %2 değişimde analiz (varsayılan %10)
);

Bağlantı Havuzlama

Supabase varsayılan olarak transaction modunda PgBouncer kullanır. Serverless ortamlarda havuzlayıcı bağlantı dizesini (port 6543), migration'lar için doğrudan bağlantıyı (port 5432) kullan:

# Havuzlayıcı — üretim uygulamasında kullan (serverless, edge)
DATABASE_URL=postgresql://postgres.ref:[sifre]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres
 
# Doğrudan — migration'lar ve CLI işlemleri için kullan
DATABASE_URL=postgresql://postgres:[sifre]@db.ref.supabase.co:5432/postgres

Kısa Transaction'lar

-- ❌ Harici çağrılarla uzun transaction — kilitleri tutar
begin;
select * from siparisler where id = 1 for update;
-- ... ödeme API'sine HTTP çağrısı (2-5 saniye) ...
update siparisler set durum = 'odendi' where id = 1;
commit;
 
-- ✅ Harici işi transaction dışında yap
-- Adım 1: doğrula, API'yi transaction dışında çağır
-- sonuc = await odemeAPI.tah(...) 
 
-- Adım 2: atomik güncelleme
begin;
update siparisler
  set durum = 'odendi', odeme_id = $1
  where id = $2 and durum = 'bekliyor'
  returning *;
commit;

SKIP LOCKED ile Kuyruk İşleme

-- Birden fazla worker, birbirini bloklamadan
update public.isler
set
  durum = 'isleniyor',
  worker_id = $1,
  baslangic = now()
where id = (
  select id from public.isler
  where durum = 'bekliyor'
  order by olusturuldu
  limit 1
  for update skip locked
)
returning *;

11. Güvenlik Kontrol Listesi

API Anahtarları

☐ NEXT_PUBLIC_ değişkenleri yalnızca publishable key içeriyor — service_role/secret asla
☐ Service role anahtarı yalnızca sunucu tarafı env değişkenlerinde (NEXT_PUBLIC_ ön eki yok)
☐ Açığa çıktıysa anahtarları döndür (Dashboard → API Settings)

Auth

☐ getSession() sunucu tarafında asla kullanılmıyor — getClaims() veya getUser() kullan
☐ Yetkilendirme için user_metadata asla kullanılmıyor — app_metadata kullan
☐ app_metadata yalnızca Admin API ile ayarlanıyor (service_role), istemciden asla
☐ JWT süresi güvenlik gereksinimlerine uygun ayarlanmış
☐ Kullanıcı silme mevcut token'ları iptal ETMİYOR — önce çıkış yaptır veya süresini bekle

RLS

☐ Public şemasındaki her tabloda RLS etkin
☐ Hassas tablolarda force row level security ayarlı
☐ RLS fonksiyonları performans için SELECT içinde sarılmış: (select auth.uid())
☐ RLS policy'lerinde kullanılan her sütunda indeks var
☐ View'lar security_invoker = true kullanıyor (Postgres 15+) veya erişim iptal edilmiş
☐ security definer fonksiyonları açık olmayan şemada (public değil)
☐ UPDATE policy'lerinin eşleşen SELECT policy'si var

Storage

☐ Upsert INSERT + SELECT + UPDATE policy'leri gerektiriyor
☐ Private bucket'lar imzalı URL kullanıyor — public URL değil
☐ Dosya yolları yol geçişini önlemek için user_id içeriyor

Veritabanı

☐ Uygulamada superuser kimlik bilgisi yok
☐ Uygulama rolünün minimum gerekli izinleri var
☐ Migration'lar güvenlik açısından incelendi (büyük tablolarda indeksler için CONCURRENTLY)
☐ Sorgu izleme için pg_stat_statements etkin

Ek: Hızlı Referans

Auth Metodu Kararı

Sunucu tarafı auth kontrolü
├── Anında iptal tespiti gerekiyor mu?
│   └── Evet → getUser() (her zaman Auth sunucusuna gider)
│   └── Hayır → getClaims() (hızlı, JWT-doğrulanmış)
│              └── Aynı istekte birden fazla bileşen mi?
│                  └── Evet → React cache() sarmalayıcısı
│                  └── Hayır → doğrudan getClaims()
└── İstemci tarafı
    └── onAuthStateChange() / getClaims()

Erişim Modeline Göre RLS Kalıbı

Herkese açık okuma, yazma kısıtı yok
  → create policy "..." using (true)

Kullanıcı satıra sahip
  → using (user_id = (select auth.uid()))

Kiracı izolasyonu
  → using (kiracı_id in (select kullanici_kiracı_idlerini_al()))

Rol tabanlı (app_metadata'dan)
  → using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')

Birleşik kullanıcı + admin
  → using (user_id = (select auth.uid()) OR (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')

Migration Güvenliği

Güvenli (kilit yok):
  ALTER TABLE ... ADD COLUMN (nullable veya varsayılan değerli)
  CREATE INDEX CONCURRENTLY
  CREATE POLICY

Dikkatli (kısa kilit):
  ALTER TABLE ... ADD COLUMN NOT NULL (önce doldur)
  ALTER TABLE ... ALTER COLUMN TYPE

Tehlikeli (tam tablo kilidi):
  CREATE INDEX (CONCURRENTLY olmadan)
  ALTER TABLE ... DROP COLUMN
  VACUUM FULL

Son güncelleme: Nisan 2026. @supabase/ssr son sürüm, asimetrik JWT anahtarları (Mayıs 2025+'tan varsayılan), yeni API anahtarı formatı (sb_publishable_xxx / sb_secret_xxx) kapsar.