Frontend'de Tenant Context — Provider'sız Yaklaşım
Next.js'te multi-tenant frontend yazarken ilk içgüdü bir TenantProvider yazmak. Ama cookie'de yaşayan bir veri için provider gerçekten gerekli mi — ve SSR'da cookie okumanın ISR'a faturası ne? Multi-tenant mimari serisinin son bölümü.
Bu yazı 3 bölümlük bir serinin üçüncü ve son bölümü. Bölüm 1: Tenant Routing'i Nereye Koyacağıma 4 Kez Karar Verdim Bölüm 2: Database-per-Tenant ve Connection Pool Patlaması Bölüm 3: Frontend Tenant Context (buradasınız)
İlk iki bölümde backend'i anlattım: isteğin hangi tenant'a ait olduğunu nasıl çözdüğümü ve o bilgiyle hangi veritabanına bağlandığımı. Sıra Next.js tarafında.
Bu bölümde frontend kararlarını anlatacağım: ilk içgüdümü (React Context ile bir TenantProvider) neden terk ettiğimi, yerine ne koyduğumu ve bu helper'ın zaman içinde performans yüzünden nasıl değiştiğini.
İlk İçgüdü: TenantProvider Yazmak
Frontend'e başlarken ilk düşündüğüm yapı klasik React Context örüntüsüydü:
// Olmayan kod — yazmadım, ama düşündüm
const TenantContext = createContext<string | null>(null)
export function TenantProvider({ children, tenantId }) {
return (
<TenantContext.Provider value={tenantId}>
{children}
</TenantContext.Provider>
)
}
export function useTenant() {
return useContext(TenantContext)
}Tanıdık bir desen — backend'deki ThreadLocal TenantContext'in frontend versiyonu gibi.
Sonra şunu fark ettim: bu provider'ın tenant bilgisini bir yerden okuması gerekiyor; büyük ihtimalle cookie'den ya da env'den. Yani zincir şu oluyordu:
- Cookie/env'den tenant'ı oku
- Provider'a aktar
useTenant()ile bileşenlere yay- Bileşenler tenant'ı kullansın
Ama 1. adımdaki kaynak zaten her yerden erişilebilir. Provider katmanı gereksiz bir dolaylama yaratıyordu; üstüne SSR ve CSR'ın provider'ı farklı şekilde ilklendirmesi gerekiyordu — yani hydration uyuşmazlığı riski.
Sade çözüme döndüm: provider yok, iki helper fonksiyon var.
getTenantId + buildApiUrl Deseni
Tüm frontend mekanizması buna indi:
export async function getTenantId(): Promise<string> {
const defaultTenant = process.env.NEXT_PUBLIC_DEFAULT_TENANT ?? 'default'
if (globalThis.window === undefined) {
return defaultTenant // SSR: yalnız env (sebebi aşağıda)
}
return getGlobalCookies()[CookieEnum.TENANT_ID] ?? defaultTenant
}
export async function buildApiUrl(path: string): Promise<string> {
const baseUrl = process.env.NEXT_PUBLIC_API ?? ''
const tenantId = await getTenantId()
return `${baseUrl}/${tenantId}${path}`
}buildApiUrl('/pages/home') şunu üretiyor:
https://api.huseyindol.com/api/v1/public/tenant1/pages/home
Yani public site'tan gelen her istek /api/v1/public/{tid}/... formatına uyuyor; backend'de PublicApiFilter bu prefix'i yakalayıp tenant context'ini kuruyor ve path'i içeride yeniden yazıyor (Bölüm 1).
Bu Fonksiyonun İlk Hali Farklıydı — ve Sitemi Yavaşlatıyordu
Yukarıdaki getTenantId'in ilk versiyonu "daha doğru" görünüyordu: server tarafında da cookie okuyordu.
// ESKİ HALİ — mantıklı görünüyor, ama pahalı
if (globalThis.window === undefined) {
const { cookies } = await import('next/headers')
const cookieStore = await cookies()
return cookieStore.get(CookieEnum.TENANT_ID)?.value ?? defaultTenant
}Mantık şuydu: "tenant bilgisi cookie'de olabilir, o zaman her ortamda cookie'yi kontrol edeyim." Kulağa titiz geliyor.
Sorun şu ki Next.js App Router'da cookies() çağırmak bir karar anlamına geliyor: "Bu render isteğe özgüdür." Cookie'ye dokunan her sayfa otomatik olarak dinamik render'a düşüyor — statik üretim ve ISR devre dışı kalıyor.
Bu helper fetcher'ın içinde olduğu için etkisi zincirleme oldu: veri çeken her sayfa dinamikleşti. Build çıktısında sayfaların yanındaki işaret ○ (static) yerine ƒ (dynamic) olarak görünmeye başladı. Blog listesi, hakkımda, yetenekler — hepsi her istekte yeniden render ediliyordu. Sunucuda gereksiz iş, ziyaretçide daha yavaş ilk byte.
Çözüm, aslında ürünün doğasını kabul etmekti: tenant sitesi tek bir tenant'a hizmet ediyor. Her deployment'ın hedefi zaten NEXT_PUBLIC_DEFAULT_TENANT ile sabit. Server tarafında cookie'ye bakmanın hiçbir bilgi kazancı yok — tenant zaten belli.
Bu yüzden SSR dalını tek satıra indirdim ve yorumu koda yazdım ki gelecekte biri (muhtemelen ben) "iyileştireyim" diye geri eklemesin:
/**
* SSR/ISR (server): yalnızca env okunur. `cookies()` BURADA ÇAĞRILMAZ — çağrılırsa
* fetcher'ı kullanan her sayfa dinamik render'a (ƒ) zorlanır ve ISR/static bozulur.
* Public site tek tenant'a hizmet ettiği için tenant cookie'si zaten set edilmez.
*/Runtime override hâlâ mümkün — ama yalnız client tarafında, gerçekten gerektiğinde.
Ders: Framework'lerde bazı API çağrıları sadece veri okumaz, render stratejisini de değiştirir. Next.js'te cookies(), headers(), searchParams bu gruptan. "Her ihtimale karşı okuyayım" refleksi, farkında olmadan tüm sitenin önbelleklenebilirliğini feda edebilir.
Üç Senaryo, Tek Kural
Elly'de tenant çözümü üç senaryoda farklı görünüyor ama altında tek prensip var:
| Senaryo | URL formatı | Tenant kaynağı |
|---|---|---|
| Public site (anonim ziyaretçi) | /api/v1/public/{tid}/... | URL path → PublicApiFilter |
| Giriş yapmış kullanıcı (kendi sitesinde) | /api/v1/... | JWT tenantId claim → JwtTenantFilter |
| Admin, başka bir tenant'ı hedeflerken | /api/v1/chat/tenant/{tid}/... | URL path → servis katmanındaki router |
Üçüncü satırda ince ama önemli bir değişiklik var. Eskiden bu URL'i de merkezî filtre çözüyordu; filtre "hangi path hangi tenant'a gider" bilgisini taşıdıkça şişiyordu (Bölüm 1'deki istisna listesi). Şimdi filtre yalnız JWT claim'ine bakıyor; "bu istek başka bir tenant'ı hedefliyor" kararı, o bilginin gerçekten ait olduğu yerde — ilgili serviste — veriliyor:
// ChatGroupTenantRouter (servis katmanı)
TenantContext.setTenantId(groupTenantId);Sonuç: routing kuralı tek ve genel kalıyor, istisna ise yereldeki koda iniyor. Filtre büyümüyor.
Provider Olmaması Ne Kazandırdı
1. Hydration uyuşmazlığı riski yok. Provider olsaydı SSR ve CSR'ın aynı değeri bağımsız şekilde üretmesi gerekirdi. Helper yaklaşımında kaynak tek: env (server) veya cookie/env (client).
2. React dışı dosyalar da tenant'a erişebiliyor. fetcher.ts, chat-config.ts gibi bileşen olmayan dosyalar useTenant() çağıramaz — hook'lar yalnız bileşen içinde çalışır. Helper her yerden çağrılabiliyor; API katmanım React'tan bağımsız kalıyor.
3. Daha az kod. Provider + wrap + hook en az 30-40 satır; helper 20 satır.
Net olayım: Provider deseni yanlış değil. Tenant bilgisi sık değişen, birden fazla yerden dinlenmesi gereken bir veri olsaydı doğru araç olurdu. Ama Elly'de tenant oturum boyunca sabit — bir deployment bir tenant'a hizmet ediyor.
Bir İstisna: Senkron Modül Yükleme
Bu yaklaşımın esnemek zorunda kaldığı bir yer var: chat ve WebRTC modülleri WebSocket bağlantısını modül yüklenirken senkron kurmak istiyor, ama getTenantId() async.
Bu yüzden chat-config.ts içinde ayrı, senkron bir tanım var:
export const CHAT_TENANT_ID = process.env.NEXT_PUBLIC_DEFAULT_TENANT ?? 'tenant1'Yani aynı bilgi iki yoldan okunuyor: async/cookie-aware yol (getTenantId) ve senkron/env-only yol (CHAT_TENANT_ID). Tek-tenant deployment'ta ikisi aynı değeri verdiği için sorun değil — ama bu, "her yerden aynı şekilde okunabilir" idealinin bir istisnası.
Aynı dosya, sonradan eklediğim WebRTC görüntülü görüşme modülünde ikinci bir görev daha üstlendi: STUN sunucu listesi de oradan geliyor (ICE_SERVERS). İlginç olan şu — bu iki değer de "build anında bilinmesi gereken, çalışma anında değişmeyen" veriler. Yani istisna rastgele değil; senkron modül init'i, doğal olarak env-sabitlerinin alanı.
Ders: Cross-cutting bir veriyi tasarlarken farklı runtime'ların farklı kısıtları olduğunu hesaba katın. SSR farklı, CSR farklı, modül init farklı. Tek bir API her yere uymayabilir; bunu önceden bilirsen tasarımı buna göre kurarsın.
Üç Bölümün Sentezi
Bu seride üç katmanda aynı kararı verdiğimi fark ettim:
| Katman | "Kim?" | "Nereye?" |
|---|---|---|
| Backend filtre | JWT'deki kullanıcı + loginSource | JWT tenantId claim'i veya public URL'deki {tid} |
| Veritabanı | (kimlik DB'ye dokunmuyor) | TenantContext → AbstractRoutingDataSource (boşsa hata) |
| Frontend | httpOnly cookie'deki token | NEXT_PUBLIC_DEFAULT_TENANT + buildApiUrl |
Üç katman aynı prensibi farklı tonlarda söylüyor:
Kimlik bilgisi ve scope bilgisi birbirine karışmamalı.
Seriyi ilk yazdığımda mimaride hâlâ ortak bir veritabanı vardı ve routing kodu "bu istek merkezî mi, tenant'a mı ait?" sorusunu her seferinde yeniden cevaplıyordu. Sonra o ortak veritabanını tamamen sildim — ve fark ettim ki prensip zaten doğruydu, sadece tam uygulanmamıştı. İstisnalar prensibin yanlış olduğunu değil, henüz sonuna kadar götürülmediğini gösteriyordu.
Mimarinin böyle bir yanı var: bazı dersleri okumak değil, yaşamak gerekiyor. Ve bazen doğru hamle yeni bir katman eklemek değil, daha önce eklediğini silmektir.
Bu Seri Burada Bitiyor, Ama Elly Devam Ediyor
Seriyi yazdığımdan bu yana Elly'ye eklenen ve yazmayı planladığım konular:
- WebRTC ile görüntülü görüşme — Sinyalleşmeyi mevcut STOMP altyapısına bindirmek, "ring-all" destek hattı modeli ve medyanın sunucuya hiç uğramaması
- Gerçek zamanlı chat mimarisi — Tek modele indirilmiş chat (her grup kendi tenant'ında), presence yönetimi, anonim ziyaretçi desteği
- K3s üzerinde deployment stratejisi — Manifest yapısı, secret yönetimi, tenant veritabanlarının StatefulSet ile ayağa kalkması
Bu seriyi faydalı bulduysanız yeni yazılar için huseyindol.com/blog sayfasını arada kontrol edebilirsiniz.
Elly ekosistemi hakkında daha fazla bilgi için: huseyindol.com/projects/elly