Tek Şablonla 13 Müşteri Sitesi: Neyi Kopyaladım, Neyi Yeniden Düşündüm
Serbest/ajans işlerinde en çok tekrar eden brief şudur: "kurumsal bir site istiyoruz, ürünlerimiz/hizmetlerimiz olsun, bir de biz kendimiz içerik girebilelim." On üçten fazla kez aynı briefi aldıktan sonra asıl mühendislik sorusu şuna dönüşüyor: her defasında sıfırdan mı kuracaksın, yoksa bir iskelet çıkarıp onu mu çoğaltacaksın? Bu yazıda, bu portföyü de besleyen aynı Next.js + NestJS + Vite + Turborepo iskeletini müşteri sitelerine taşırken neyi olduğu gibi kopyaladığımı, neyi her seferinde yeniden düşündüğümü anlatıyorum.
Şablonlaştırmanın ekonomisi
Bir "kurumsal site" projesinin çoğu tarafı aslında iş mantığı değil, altyapı: admin girişi nasıl korunur, görsel nereye yüklenir, içerik hangi kategorilere ayrılır, sayfalama nasıl çalışır. Bunları her projede yeniden tasarlamak hem zaman kaybı hem de tutarsızlık riski. Kendi portföyümde çözdüğüm bu problemleri müşteri sitelerine de aynen taşıyorum — aynı üç parçalı yapı (Next.js site, NestJS API, Vite admin panel), aynı Prisma/MariaDB veri katmanı, aynı MinIO tabanlı görsel depolama. Şu an canlıda olan müşteri sitelerinden bir kaçı — Endeks Bilişim, Dijilab, Fa Dijital, DMT Dizayn — birebir bu stack'i kullanıyor.
Bunun tersi de önemli: DilX gibi gerçek bir SaaS ürünüyse (canlı sesli çeviri, WebRTC, kendi gerçek zamanlı altyapısı), şablona zorlamıyorum — o proje kendi stack'ini hak ediyor. Şablonlaştırmanın sınırı burada: "içerik yönetimi gereken kurumsal/e-ticaret sitesi" kalıbına giren her iş bu iskeleti kullanıyor, kalıba girmeyen iş kullanmıyor.
Ne olduğu gibi kopyalanıyor: admin auth
Her müşteri sitesinin bir admin paneli var ve her birinin "kim düzenleyebilir" sorusuna bir cevabı olması gerekiyor. Bu ölçekte (tek yönetici, düşük trafik) tam bir OAuth/session sistemi kurmak zamana değmiyor — bunun yerine basit ama net bir Bearer token guard'ı kullanıyorum:
// apps/api/src/auth/admin.guard.ts
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token || token !== this.config.getOrThrow<string>('ADMIN_API_TOKEN')) {
throw new UnauthorizedException();
}
return true;
}
}
Bilinçli olarak "placeholder" diye işaretliyorum — kod içinde de öyle yazıyor. Bunun nedeni over-engineering'den kaçınmak: bu ölçekte gerçek bir tehdit modeli, çoklu kullanıcı, rol sistemi yok. Token ortam değişkeninde tutuluyor, @UseGuards(AdminGuard) ile korunması gereken her route'a tek satırla ekleniyor. İleride gerçekten çok kullanıcılı bir müşteri gelirse (birden fazla editör, farklı yetki seviyeleri), o zaman OTP/email tabanlı oturum sistemine geçişin nereye ekleneceği zaten belli — guard'ın içi değişir, @UseGuards çağrıları değişmez.
Ne olduğu gibi kopyalanıyor: görsel depolama proxy'si
Her müşteri sitesi ürün/proje görselleri yüklüyor. MinIO bucket'ını doğrudan public yapmak yerine, API üzerinden proxy'liyorum:
// apps/api/src/storage/storage.controller.ts
@Post('upload')
@UseGuards(AdminGuard)
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile() file: UploadedMulterFile, @Query('folder') folder = 'uploads') {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) throw new BadRequestException('unsupported file type');
const ext = file.originalname.split('.').pop() || 'bin';
const key = `${folder}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const url = await this.storage.upload(key, file.buffer, file.mimetype);
return { key, url };
}
// Public — bucket'ın kendisi hiç public olmak zorunda kalmadan objeyi API üzerinden stream ediyor.
@Get(':folder/:filename')
async serve(@Param('folder') folder: string, @Param('filename') filename: string, @Res() res: Response) {
const { body, contentType } = await this.storage.get(`${folder}/${filename}`);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(body);
}
Bu tasarımın üç somut faydası var: (1) bucket erişim politikası her ortamda aynı kalıyor — hiçbir müşteri sitesinde "bucket'ı public yapmayı unuttum" hatası olamaz, çünkü zaten public değil; (2) görsel URL'leri her zaman kendi API domain'in altında (/api/storage/projects/...), yani CDN/proxy/CORS ayarları her müşteri sitesinde birebir aynı; (3) immutable cache header'ı dosya adına gömülü rastgele suffix sayesinde güvenli — aynı key asla farklı bir içerikle tekrar yazılmıyor, o yüzden agresif cache'lemek risksiz.
Her seferinde yeniden düşünülen: kategori modeli
İçerik kategorileri her müşteride farklı bir kelime dağarcığı taşıyor ama aynı şekli koruyor: sabit bir enum, sayfalanan liste, filtre başına doğru sayım.
const ALL_CATEGORIES: ProjectCategory[] = ['saas', 'corporate', 'ecommerce', 'demo', 'blog', 'other'];
async list({ page, pageSize, category }: ListProjectsQuery) {
const where = category ? { category } : undefined;
const [items, total, counts] = await Promise.all([
this.prisma.project.findMany({ where, orderBy: [{ name: 'asc' }], skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.project.count({ where }),
this.prisma.project.groupBy({ by: ['category'], _count: true }),
]);
const categoryCounts = Object.fromEntries(ALL_CATEGORIES.map((c) => [c, 0])) as Record<ProjectCategory, number>;
for (const row of counts) categoryCounts[row.category] = row._count;
return { items, total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)), categoryCounts };
}
Buradaki ince nokta: categoryCounts hesaplanırken where filtresi kullanılmıyor, ayrı bir groupBy sorgusu tüm katalog üzerinden çalışıyor. Sebep basit — kullanıcı "e-ticaret" sekmesindeyken diğer sekmelerin (kurumsal, demo, vb.) yanındaki sayının da doğru kalması lazım; sayım aktif filtreye göre değişseydi, filtrelenmemiş sekmelerin sayacı hep sıfır görünürdü. Enum'un kendisi (saas, corporate, ecommerce, demo, blog, other) her müşteride farklı görsel etiketlerle sunuluyor ama şema aynı kalıyor — bu da schema-first yaklaşımın gerçek faydası: kategori isimleri değişse bile pagination/count mantığı hiç dokunulmadan taşınıyor.
Sonuç
Bir şablonu birden fazla müşteriye taşımanın gerçek sanatı, "her şeyi tekrar kullan" değil, "hangi katmanın iş mantığından bağımsız olduğunu doğru ayır." Auth guard'ı, storage proxy'si, pagination/count deseni — bunların hiçbiri müşteriye özel bir şey bilmiyor, o yüzden değişmeden taşınıyorlar. Kategori isimleri, tasarım, içerik — bunlar her müşteride yeniden yazılıyor çünkü zaten öyle olmaları gerekiyor. Bu ayrımı baştan netleştirmek, on üçüncü siteyi de birinciyle aynı hızda teslim etmeyi mümkün kılıyor.