Yeni·E-Ticaret Pro Paketi Yayında — Entegre Ödeme, Stok ve Sipariş YönetimiBlog·2025'te Küçük İşletmeler İçin Web Tasarım TrendleriKampanya·Mayıs Ayına Özel %20 İndirim — Kartvizit & Başlangıç Paketleriİçgörü·Müşteri Projelerinde Dönüşüm Oranı Ortalama %40 Artış SağlandıBlog·SEO'ya Yeni Başlayanlar İçin Temel Rehber — Ücretsiz İndirHaber·Ankara'da Yeni Çözüm Ortaklıkları ile Hizmet Ağı GenişliyorGüncelleme·Tüm Projeler İçin Ücretsiz SSL, CDN ve Hız Optimizasyonu DahilYeni·Çözümler Sayfası Açıldı — Sektöre Özel Web ÇözümleriKampanya·Ücretsiz Web Sitesi Değerlendirmesi — Bugün Başvurİçgörü·Ortalama Proje Teslim Süresi: 14 Gün — GarantiliYeni·E-Ticaret Pro Paketi Yayında — Entegre Ödeme, Stok ve Sipariş YönetimiBlog·2025'te Küçük İşletmeler İçin Web Tasarım TrendleriKampanya·Mayıs Ayına Özel %20 İndirim — Kartvizit & Başlangıç Paketleriİçgörü·Müşteri Projelerinde Dönüşüm Oranı Ortalama %40 Artış SağlandıBlog·SEO'ya Yeni Başlayanlar İçin Temel Rehber — Ücretsiz İndirHaber·Ankara'da Yeni Çözüm Ortaklıkları ile Hizmet Ağı GenişliyorGüncelleme·Tüm Projeler İçin Ücretsiz SSL, CDN ve Hız Optimizasyonu DahilYeni·Çözümler Sayfası Açıldı — Sektöre Özel Web ÇözümleriKampanya·Ücretsiz Web Sitesi Değerlendirmesi — Bugün Başvurİçgörü·Ortalama Proje Teslim Süresi: 14 Gün — Garantili
ilkkod
Geliştirici Araçları ve İş Akışı

Türkiye'de Micro-SaaS Kurma: Next.js + PayTR Abonelik + Better Auth ile Aylık Gelir Modeli

Next.js 16, PayTR kart saklama, Better Auth ve Resend ile Türkiye pazarı için sıfırdan çalışan bir Micro-SaaS abonelik sistemi nasıl kurulur? Teknik mimari ve ilk müşteri stratejisi.

İlker
26 Mart 2026
25 dk
Türkiye'de Micro-SaaS Kurma: Next.js + PayTR Abonelik + Better Auth ile Aylık Gelir Modeli

Önemli Not: Bu yazıdaki teknik bilgiler yazım tarihi itibarıyla geçerlidir. Kullanılan kütüphaneler, API'ler ve servisler zaman içinde değişebilir. Ücretlendirme, yasal düzenleme ve vergi konularında ilgili resmi kaynakları ve uzmanları referans alınız. Bu içerik bilgilendirme amaçlı olup herhangi bir finansal veya hukuki tavsiye niteliği taşımamaktadır.

Türkiye'de aylık düzenli gelir getiren bir yazılım ürünü kurmak artık tek başına çalışan bir geliştirici için mümkün. Ancak Türkçe kaynaklarda bu konuya dair pratik, teknik içerik yok denecek kadar az. Bu yazıda sıfırdan çalışan bir Micro-SaaS altyapısı nasıl kurulur, PayTR ile tekrarlayan ödeme nasıl entegre edilir ve Türk pazarında ilk müşteri nasıl bulunur — bunları somut kod örnekleriyle ele alacağız.

Bu projede de kullandığımız stack'i esas alıyoruz: Next.js 16 + Better Auth + Drizzle ORM + PayTR + Resend. Hepsinin Türkçe rehberi neredeyse yok; bu yazı bu boşluğu kapatmayı amaçlıyor.


Micro-SaaS Nedir? Neden Türkiye?

Micro-SaaS; tek bir geliştiricinin veya küçük bir ekibin yarattığı, belirli bir problemi çözen, aylık abonelikle gelir elde eden yazılım ürününe verilen addır. Venture capital gerektirmez, büyük mühendislik ekibi gerektirmez. Bir problemi bul, çöz, sat.

Türkiye KOBİ Pazarının Fırsatları

Türkiye'de 3,5 milyonun üzerinde aktif KOBİ var. Bu şirketlerin büyük çoğunluğu dijitalleşme sürecinde ve henüz yazılım aboneliği satın alma alışkanlığı kazanmamış durumda. Bu aynı zamanda bir fırsat penceresi anlamına geliyor:

  • Fatura ve e-SMM takip araçları — Muhasebeciyle konuşmayı basitleştiren paneller
  • Randevu yönetim sistemleri — Kuaför, diş hekimi, danışman gibi hizmet sektörü
  • Stok ve sipariş takip — Küçük üretici ve toptancılar için
  • İçerik planlama araçları — Sosyal medya ajansları ve bireysel içerik üreticileri
  • Müşteri ilişki yönetimi (mini CRM) — Muhasebe yazılımı almaya hazır olmayan işletmeler

Fiyatlandırma sweet spot: Türkiye KOBİ pazarı için aylık ücretlendirme planlamanızı yaparken rakiplerinizin fiyatlarını ve hedef kitlenizin ödeme gücünü analiz edin. Güncel fiyatlandırma stratejileri için benzer ürünleri inceleyin ve A/B test yapın.


Stack Seçimi

Micro-SaaS için doğru stack seçimi, hem geliştirme hızını hem bakım kolaylığını doğrudan etkiler.

KatmanSeçimNeden
FrameworkNext.js 16 (App Router)Full-stack, Vercel deploy, SSR/SSG
AuthBetter Auth v1.5.5Plugin sistemi, magic link, ücretsiz
VeritabanıDrizzle ORM + PostgreSQLTypeScript-first, düşük overhead
ÖdemePayTR (kart saklama)Türkiye TCMB lisanslı, hızlı onboarding
EmailResend + React EmailÜcretsiz başlangıç, Türkçe karakter desteği
DeploymentVercelEdge network, preview deploy
RuntimeBunHız, npm uyumluluğu

Neden PayTR?

PayTR, Türkiye'de TCMB (Türkiye Cumhuriyet Merkez Bankası) lisanslı bir ödeme kuruluşudur. Abonelik sistemi için kritik olan kart saklama (tokenizasyon) özelliğini ayrı bir Sanal POS başvurusuyla sunar. Bu sayede müşterinin kartını PCI DSS uyumlu şekilde PayTR altyapısında tutabilir, her ay ödemeyi siz API üzerinden tetikleyebilirsiniz.

Güncel başvuru süreci, komisyon oranları ve teknik şartlar için PayTR Developer Portal'u inceleyin.


Proje Yapısı

micro-saas/
├── app/
│   ├── (auth)/
│   │   └── giris/page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx          # Auth guard
│   │   ├── page.tsx            # Ana panel
│   │   └── ayarlar/page.tsx    # Abonelik yönetimi
│   ├── (marketing)/
│   │   └── page.tsx            # Landing page
│   ├── api/
│   │   ├── auth/[...all]/route.ts
│   │   ├── paytr/
│   │   │   ├── token/route.ts       # İlk ödeme token'ı
│   │   │   └── callback/route.ts    # Postback webhook
│   │   └── subscription/
│   │       ├── charge/route.ts      # Manuel ödeme tetikleme
│   │       └── cancel/route.ts      # İptal
│   └── globals.css
├── lib/
│   ├── auth.ts              # Better Auth config
│   ├── db/
│   │   ├── index.ts
│   │   └── schema/
│   │       ├── users.ts
│   │       └── subscriptions.ts
│   └── email/
│       └── send.ts
├── emails/                  # React Email şablonları
├── proxy.ts                 # Route koruması (Next.js 16)
├── drizzle.config.ts
└── package.json

Veritabanı Şeması (Drizzle ORM)

// lib/db/schema/subscriptions.ts import { pgTable, text, timestamp, boolean, integer, } from "drizzle-orm/pg-core" export const subscriptions = pgTable("subscriptions", { id: text("id").primaryKey(), userId: text("user_id").notNull(), // PayTR token'ları (PCI DSS uyumlu — gerçek kart numarası saklanmaz) paytrUtoken: text("paytr_utoken"), // Kullanıcı token'ı (postback'ten gelir) paytrCtoken: text("paytr_ctoken"), // Kart token'ı (Kart Listesi API'sinden) // Plan plan: text("plan").notNull().default("trial"), // "trial" | "monthly" | "yearly" status: text("status").notNull().default("active"), // "active" | "cancelled" | "past_due" // Tarihler trialEndsAt: timestamp("trial_ends_at"), currentPeriodStart: timestamp("current_period_start"), currentPeriodEnd: timestamp("current_period_end"), cancelledAt: timestamp("cancelled_at"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), // Ödeme geçmişi için son durum lastPaymentStatus: text("last_payment_status"), // "success" | "failed" failedAttempts: integer("failed_attempts").default(0), }) export const invoices = pgTable("invoices", { id: text("id").primaryKey(), userId: text("user_id").notNull(), merchantOid: text("merchant_oid").notNull().unique(), amount: integer("amount").notNull(), // Kuruş cinsinden (TL × 100) currency: text("currency").notNull().default("TL"), status: text("status").notNull(), // "success" | "failed" | "refunded" paymentType: text("payment_type"), createdAt: timestamp("created_at").defaultNow(), })
bunx drizzle-kit push

Better Auth ile Kullanıcı Yönetimi

Kurulum

bun add better-auth

Auth Konfigürasyonu

// lib/auth.ts import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { magicLink } from "better-auth/plugins" import { admin } from "better-auth/plugins" import { db } from "@/lib/db" import { resend } from "@/lib/email/send" export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", }), emailAndPassword: { enabled: true, }, plugins: [ // Müşteriler şifresiz magic link ile giriş yapar magicLink({ sendMagicLink: async ({ email, url }) => { await resend.emails.send({ from: "Uygulamanız <noreply@domain.com>", to: email, subject: "Giriş Linkiniz", html: ` <p>Merhaba,</p> <p>Giriş yapmak için aşağıdaki butona tıklayın:</p> <a href="${url}" style="background:#000;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;"> Giriş Yap </a> <p>Link 5 dakika geçerlidir.</p> `, }) }, expiresIn: 300, // 5 dakika allowedAttempts: 1, }), // Kullanıcı rollerini admin plugin ile yönet admin(), ], })

Rol Yönetimi: Trial → Premium

Better Auth'ın admin plugin'i kullanıcılara özel roller (role alanı) atamanızı sağlar. Abonelik flow'unuz şöyle işler:

  1. Kullanıcı kayıt olduğunda role: "trial" atanır
  2. İlk başarılı ödeme postback'inde role: "premium" olarak güncellenir
  3. Ödeme başarısız olduğunda role: "trial" veya role: "past_due" olarak düşürülür
// PayTR postback callback içinde rol güncelleme import { auth } from "@/lib/auth" await auth.api.setRole({ userId: user.id, role: "premium", })

proxy.ts ile Route Koruması (Next.js 16)

Next.js 16'da middleware.ts yerine proxy.ts kullanılır. Dashboard route'larını koruyalım:

// proxy.ts (proje kökünde) import { NextRequest, NextResponse } from "next/server" import { auth } from "@/lib/auth" export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl // Dashboard koruması if (pathname.startsWith("/dashboard")) { const session = await auth.api.getSession({ headers: request.headers, }) if (!session) { return NextResponse.redirect(new URL("/giris", request.url)) } // Trial süresi dolmuş ve premium değilse yükselt if ( session.user.role === "trial" && pathname !== "/dashboard/abonelik" ) { // Trial kontrolü subscription tablosundan yapılır // Buraya isterseniz ek kontrol ekleyebilirsiniz } } return NextResponse.next() }

PayTR ile Abonelik Akışı

PayTR'ın abonelik sistemi iki aşamadan oluşur:

  1. İlk ödeme — iframe üzerinden, 3DS ile. Postback'te utoken (kullanıcı token'ı) gelir.
  2. Sonraki ödemeler — Kart Listesi API'sinden ctoken alınır, non-3DS API ile ödeme tetiklenir.

Önemli: PayTR tekrarlayan ödeme için ayrı bir Sanal POS başvurusu gerektirir. Abonelik POS'u olmadan bu akış çalışmaz. Detaylar için PayTR yetkili satış ile iletişime geçin.

Adım 1 — İlk Ödeme Token Endpoint'i

// app/api/paytr/token/route.ts import { NextRequest, NextResponse } from "next/server" import { createHmac } from "crypto" import { auth } from "@/lib/auth" import { headers } from "next/headers" export async function POST(request: NextRequest) { const session = await auth.api.getSession({ headers: await headers(), }) if (!session) { return NextResponse.json({ error: "Yetkisiz" }, { status: 401 }) } const merchantId = process.env.PAYTR_MERCHANT_ID! const merchantKey = process.env.PAYTR_MERCHANT_KEY! const merchantSalt = process.env.PAYTR_MERCHANT_SALT! const merchantOid = `sub_${session.user.id}_${Date.now()}` const userIp = request.headers.get("x-forwarded-for")?.split(",")[0] ?? "127.0.0.1" const email = session.user.email! // Fiyatı kuruş cinsinden girin (örn: 50000 = 500 TL) // Gerçek fiyatınızı environment variable veya plan config'den alın const paymentAmount = Number(process.env.PLAN_PRICE_KURUS ?? "0") const userBasket = JSON.stringify([ ["Aylık Plan", (paymentAmount / 100).toFixed(2), 1], ]) const userBasketEncoded = Buffer.from(userBasket).toString("base64") const noInstallment = "1" const maxInstallment = "0" const currency = "TL" const testMode = process.env.NODE_ENV === "production" ? "0" : "1" // HMAC-SHA256 token formülü (iframe Step 1) const hashString = [ merchantId, userIp, merchantOid, email, String(paymentAmount), userBasketEncoded, noInstallment, maxInstallment, currency, testMode, merchantSalt, ].join("") const paytrToken = createHmac("sha256", merchantKey) .update(hashString) .digest("base64") const params = new URLSearchParams({ merchant_id: merchantId, user_ip: userIp, merchant_oid: merchantOid, email, payment_amount: String(paymentAmount), paytr_token: paytrToken, user_basket: userBasketEncoded, no_installment: noInstallment, max_installment: maxInstallment, currency, test_mode: testMode, merchant_ok_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?payment=success`, merchant_fail_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/abonelik?payment=failed`, // Kart saklama için zorunlu store_card: "1", lang: "tr", }) const response = await fetch("https://www.paytr.com/odeme/api/get-token", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" }, }) const data = await response.json() as { status: string; token?: string; reason?: string } if (data.status !== "success" || !data.token) { console.error("PayTR token hatası:", data.reason) return NextResponse.json({ error: "Ödeme başlatılamadı" }, { status: 500 }) } return NextResponse.json({ token: data.token, merchantOid }) }

Adım 2 — Postback Endpoint'i (Webhook)

PayTR, ödeme sonucunu sizin belirttiğiniz callback URL'ye POST atar. Yanıt olarak düz metin OK dönmeniz zorunludur.

// app/api/paytr/callback/route.ts import { NextRequest, NextResponse } from "next/server" import { createHmac } from "crypto" import { db } from "@/lib/db" import { subscriptions, invoices } from "@/lib/db/schema/subscriptions" import { auth } from "@/lib/auth" import { eq } from "drizzle-orm" import { sendPaymentSuccessEmail, sendPaymentFailedEmail } from "@/lib/email/send" export async function POST(request: NextRequest) { const formData = await request.formData() const merchantOid = formData.get("merchant_oid") as string const status = formData.get("status") as string const totalAmount = formData.get("total_amount") as string const hash = formData.get("hash") as string const utoken = formData.get("utoken") as string // Kart tokenizasyon için kritik const testMode = formData.get("test_mode") as string const merchantKey = process.env.PAYTR_MERCHANT_KEY! const merchantSalt = process.env.PAYTR_MERCHANT_SALT! // Hash doğrulama const hashString = merchantOid + merchantSalt + status + totalAmount const expectedHash = createHmac("sha256", merchantKey) .update(hashString) .digest("base64") if (expectedHash !== hash) { console.error("PayTR hash uyuşmazlığı") return new NextResponse("HASH_MISMATCH", { status: 400 }) } // merchant_oid formatımız: "sub_{userId}_{timestamp}" const userId = merchantOid.split("_")[1] if (status === "success") { // utoken'ı kaydet (sonraki ödemeler için zorunlu) await db .update(subscriptions) .set({ paytrUtoken: utoken, status: "active", lastPaymentStatus: "success", failedAttempts: 0, currentPeriodStart: new Date(), // Aylık plan için bir sonraki ödeme tarihi currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), updatedAt: new Date(), }) .where(eq(subscriptions.userId, userId)) // Kullanıcı rolünü premium yap await auth.api.setRole({ userId, role: "premium" }) // Fatura kaydet await db.insert(invoices).values({ id: crypto.randomUUID(), userId, merchantOid, amount: Number(totalAmount), currency: "TL", status: "success", }) // Hoşgeldiniz + fatura emaili gönder await sendPaymentSuccessEmail(userId) } else { // Ödeme başarısız const sub = await db.query.subscriptions.findFirst({ where: eq(subscriptions.userId, userId), }) const newFailedAttempts = (sub?.failedAttempts ?? 0) + 1 await db .update(subscriptions) .set({ lastPaymentStatus: "failed", failedAttempts: newFailedAttempts, status: newFailedAttempts >= 3 ? "past_due" : "active", updatedAt: new Date(), }) .where(eq(subscriptions.userId, userId)) // Dunning emaili gönder await sendPaymentFailedEmail(userId, newFailedAttempts) } // PayTR düz metin "OK" bekler — JSON veya HTML döndürmeyin! return new NextResponse("OK", { headers: { "Content-Type": "text/plain" }, }) }

Adım 3 — Tekrarlayan Ödeme Tetikleme

İlk ödeme sonrası her ay ödemeyi siz tetiklersiniz. PayTR otomatik çekim yapmaz.

// lib/paytr/charge-subscription.ts import { createHmac } from "crypto" import { db } from "@/lib/db" import { subscriptions } from "@/lib/db/schema/subscriptions" import { eq } from "drizzle-orm" export async function chargeSubscription(userId: string): Promise<boolean> { const sub = await db.query.subscriptions.findFirst({ where: eq(subscriptions.userId, userId), }) if (!sub?.paytrUtoken || !sub?.paytrCtoken) { throw new Error("PayTR token bulunamadı") } const merchantId = process.env.PAYTR_MERCHANT_ID! const merchantKey = process.env.PAYTR_MERCHANT_KEY! const merchantSalt = process.env.PAYTR_MERCHANT_SALT! const merchantOid = `sub_${userId}_${Date.now()}` const userIp = "127.0.0.1" // Tekrarlayan ödemede gerçek IP şartı yok const paymentAmount = Number(process.env.PLAN_PRICE_KURUS ?? "0") const currency = "TL" const testMode = process.env.NODE_ENV === "production" ? "0" : "1" const paymentType = "card" const installmentCount = "0" const nonThreeD = "1" // Tekrarlayan ödemeler zorunlu olarak non-3DS // HMAC formülü (recurring için farklı!) const hashString = [ merchantId, userIp, merchantOid, sub.userId, // email yerine userId — kendi email'inizi kullanın String(paymentAmount), paymentType, installmentCount, currency, testMode, nonThreeD, merchantSalt, ].join("") const paytrToken = createHmac("sha256", merchantKey) .update(hashString) .digest("base64") const params = new URLSearchParams({ merchant_id: merchantId, user_ip: userIp, merchant_oid: merchantOid, email: sub.userId, // Kullanıcı emailini buraya ekleyin payment_amount: String(paymentAmount), paytr_token: paytrToken, payment_type: paymentType, installment_count: installmentCount, currency, test_mode: testMode, non_3d: nonThreeD, recurring_payment: "1", utoken: sub.paytrUtoken, ctoken: sub.paytrCtoken, }) const response = await fetch("https://www.paytr.com/odeme", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" }, }) const data = await response.json() as { status: string } return data.status === "success" }

Cron Job ile Otomatik Ödeme Tetikleme

Vercel Cron (Pro plan) ile her gün ödeme tarihi gelen kullanıcıları kontrol edin:

// app/api/cron/charge/route.ts import { NextRequest, NextResponse } from "next/server" import { db } from "@/lib/db" import { subscriptions } from "@/lib/db/schema/subscriptions" import { and, eq, lte } from "drizzle-orm" import { chargeSubscription } from "@/lib/paytr/charge-subscription" export async function GET(request: NextRequest) { // Vercel Cron auth header kontrolü const authHeader = request.headers.get("authorization") if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { return NextResponse.json({ error: "Yetkisiz" }, { status: 401 }) } // Bugün ödeme zamanı gelen aktif abonelikleri bul const today = new Date() const dueSubscriptions = await db.query.subscriptions.findMany({ where: and( eq(subscriptions.status, "active"), eq(subscriptions.plan, "monthly"), lte(subscriptions.currentPeriodEnd, today), ), }) const results = await Promise.allSettled( dueSubscriptions.map((sub) => chargeSubscription(sub.userId)) ) const succeeded = results.filter((r) => r.status === "fulfilled").length const failed = results.filter((r) => r.status === "rejected").length return NextResponse.json({ succeeded, failed }) }
// vercel.json { "crons": [ { "path": "/api/cron/charge", "schedule": "0 9 * * *" } ] }

İyzico Abonelik Ürünü Alternatifi

PayTR'a alternatif olarak İyzico'nun kendi abonelik ürünü mevcuttur. İyzico abonelik sistemi farklı bir mimariyle çalışır:

  • Merchant paneli veya API üzerinden abonelik planları oluşturursunuz (günlük/haftalık/aylık/yıllık)
  • İyzico ödemeleri otomatik olarak çeker — siz cron job kurmak zorunda kalmazsınız
  • Webhook'lar: subscription.order.success ve subscription.order.failure
  • Kendi yönetim paneliyle abonelik durumlarını takip edebilirsiniz

Ücreti ve şartları için güncel bilgiye İyzico'nun resmi abonelik dokümantasyonundan ulaşın.

PayTR vs İyzico Abonelik: Hangisi?

KriterPayTR (kart saklama)İyzico (abonelik ürünü)
Ödeme zamanlamasıSiz tetiklersinizİyzico otomatik çeker
KontrolTam kontrolPlatform yönetiyor
ÖzelleştirmeYüksekOrta
Ek ücretAyrı POS başvurusuAbonelik modülü ücreti
WebhookVar (callback URL)Var (subscription events)
Türkiye pazarıYaygınYaygın

Küçük ölçekli başlangıç için İyzico abonelik ürünü daha hızlı entegre edilebilir. Daha fazla kontrol ve özelleştirme istiyorsanız PayTR kart saklama daha uygun.


Email Akışları (Resend + React Email)

bun add resend @react-email/components

Email Gönderim Modülü

// lib/email/send.ts import { Resend } from "resend" import { db } from "@/lib/db" import { users } from "@/lib/db/schema/users" import { eq } from "drizzle-orm" import WelcomeEmail from "@/emails/welcome" import PaymentFailedEmail from "@/emails/payment-failed" export const resend = new Resend(process.env.RESEND_API_KEY) export async function sendPaymentSuccessEmail(userId: string) { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }) if (!user) return await resend.emails.send({ from: "Uygulamanız <noreply@domain.com>", to: user.email, subject: "Ödemeniz Alındı — Premium'a Hoş Geldiniz!", react: <WelcomeEmail name={user.name ?? "Değerli Kullanıcı"} />, }) } export async function sendPaymentFailedEmail(userId: string, attempt: number) { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }) if (!user) return await resend.emails.send({ from: "Uygulamanız <noreply@domain.com>", to: user.email, subject: attempt === 1 ? "Ödemenizde Bir Sorun Oluştu" : `Ödeme Hatası (${attempt}. Deneme) — Lütfen Kartınızı Güncelleyin`, react: <PaymentFailedEmail attempt={attempt} />, }) } export async function sendTrialExpiringEmail(userId: string, daysLeft: number) { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }) if (!user) return await resend.emails.send({ from: "Uygulamanız <noreply@domain.com>", to: user.email, subject: `Trial Süreniz ${daysLeft} Gün İçinde Bitiyor`, html: ` <p>Merhaba ${user.name ?? ""},</p> <p>Trial süreniz <strong>${daysLeft} gün</strong> sonra dolacak.</p> <p>Kesintisiz kullanım için planınıza geçin:</p> <a href="${process.env.NEXT_PUBLIC_URL}/dashboard/abonelik"> Planı Yükselt </a> `, }) }

Ödeme Başarısız Email Şablonu (React Email)

// emails/payment-failed.tsx import { Html, Body, Container, Text, Button, Hr } from "@react-email/components" interface PaymentFailedEmailProps { attempt: number } export default function PaymentFailedEmail({ attempt }: PaymentFailedEmailProps) { const isLastWarning = attempt >= 2 return ( <Html lang="tr"> <Body style={{ fontFamily: "Inter, sans-serif", backgroundColor: "#f4f4f5" }}> <Container style={{ maxWidth: 560, margin: "0 auto", padding: 24, backgroundColor: "#fff", borderRadius: 8 }}> <Text style={{ fontSize: 20, fontWeight: 700, color: "#18181b" }}> Ödemenizde Bir Sorun Oluştu </Text> <Text style={{ color: "#52525b", lineHeight: 1.6 }}> {isLastWarning ? "Kartınızdan ödeme alınamadı. Hesabınız askıya alınmamak için kart bilgilerinizi lütfen güncelleyin." : "Ödeme işleminiz gerçekleştirilemedi. Birkaç gün içinde tekrar deneyeceğiz."} </Text> <Button href={`${process.env.NEXT_PUBLIC_URL}/dashboard/abonelik`} style={{ backgroundColor: "#18181b", color: "#fff", padding: "12px 24px", borderRadius: 6, textDecoration: "none", display: "inline-block", }} > Kart Bilgilerimi Güncelle </Button> <Hr style={{ margin: "24px 0", borderColor: "#e4e4e7" }} /> <Text style={{ fontSize: 12, color: "#a1a1aa" }}> Bu emaili yanlışlıkla aldıysanız destek ekibimizle iletişime geçin. </Text> </Container> </Body> </Html> ) }

Landing Page SEO

Next.js Metadata API

// app/(marketing)/page.tsx import type { Metadata } from "next" export const metadata: Metadata = { title: "Ürün Adınız — Türkiye'nin [Çözdüğünüz Problem] Çözümü", description: "KOBİ'ler için [çözdüğünüz problem] platformu. Dakikalar içinde başlayın, aylık abonelikle büyüyün.", keywords: [ "saas türkiye", "kobiler için yazılım", // Ürününüze özgü keyword'ler ekleyin ], openGraph: { title: "Ürün Adınız", description: "KOBİ'ler için [problem] çözümü", url: "https://www.domain.com", siteName: "Ürün Adınız", locale: "tr_TR", type: "website", }, alternates: { canonical: "https://www.domain.com", }, }

JSON-LD SaaS Şeması

// app/(marketing)/page.tsx içinde function SaaSSchema() { const schema = { "@context": "https://schema.org", "@type": "SoftwareApplication", name: "Ürün Adınız", applicationCategory: "BusinessApplication", operatingSystem: "Web", description: "KOBİ'ler için [çözdüğünüz problem] platformu.", offers: { "@type": "Offer", availability: "https://schema.org/InStock", priceCurrency: "TRY", seller: { "@type": "Organization", name: "Şirket Adınız", }, }, inLanguage: "tr-TR", url: "https://www.domain.com", } return ( <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} /> ) }

Landing Page Temel Bölümleri

Dönüşüm odaklı bir SaaS landing page'i için şu bölümleri öneririz:

  1. Hero — Ne yapıyorsunuz, kim için, neden şimdi (3 saniyede anlaşılsın)
  2. Problem → Çözüm — Kullandığınız mevcut yöntem vs sizin çözümünüz
  3. Özellikler — Maksimum 6 özellik, her biri bir faydaya bağlı
  4. Sosyal kanıt — Müşteri yorumları veya vaka çalışmaları
  5. Fiyatlandırma — Şeffaf, karşılaştırmalı plan tablosu
  6. SSS — En sık sorulan 5-8 soru (featured snippet için)
  7. CTA — Ücretsiz deneme veya demo talebi

İlk 10 Müşteri Stratejisi (Türk Pazarı)

Global SaaS pazarlama taktiklerinin çoğu Türkiye'de çalışmaz. Türk pazarı için işe yarayan kanallar:

1. LinkedIn Türkiye

Türkiye'de 12M+ kullanıcısıyla LinkedIn, B2B SaaS satışı için en etkili kanal. Strateji:

  • Hedef segmentinizdeki karar vericilere kişisel mesaj (cold DM değil, bir içerikle başlayın)
  • Problemle ilgili eğitici içerikler paylaşın (rakamlar, vaka çalışması, kısa video)
  • Yorumlarda değer katarak görünürlük artırın

2. Sektörel WhatsApp ve Telegram Grupları

Türk KOBİ sahipleri sektörel gruplarında oldukça aktif. Spam yapmadan, gerçekten değer katan yorumlar ve bağlantılarla tanınırlık oluşturun. Doğrudan ürün tanıtımı yerine problem tartışmalarına katılın.

3. Bionluk

Türkiye'nin en büyük freelance marketplace'i. SaaS ürününüzü "hizmet" olarak sunarak ilk kullanıcıları edinebilirsiniz. Örneğin: "Randevu yönetim paneli kurulumu + 3 ay destek" paketi.

4. r10.net ve Sektörel Forumlar

Hedef sektörünüze göre r10.net, websitesi bölümü veya sektörel forumlar organik satışa yatkın. Problemlerle ilgili gerçek yardımlar yapın, imzanıza ürün linkini ekleyin.

5. Soğuk Email (B2B)

Türk KOBİ'ler email'e açık, ancak generic emailler işe yaramaz. Sektöre özel, kısa (max 5 cümle) ve net bir değer önerisi içeren emailler deneyin.

İlk 10 Müşteri Sonrası

İlk 10 ödeme yapan müşteriye aşırı değer sunun:

  • Onboarding görüşmeleri yapın
  • Her geri bildirimi kaydedin
  • Referans/yorum isteyin (net promoter score)
  • Churn etmelerine izin vermeyin

Dunning Yönetimi (Ödeme Başarısız Akışı)

Abonelik işinde gelir kayıplarının en büyük nedeni churn değil, dunning — yani ödeme sorunlarının yönetilmemesi. Önerilen akış:

GünEylem
0Ödeme başarısız → hemen email gönder
3Tekrar dene → başarısız → "kart güncelle" emaili
7Son uyarı emaili — erişim kısıtlanacak
10Erişimi kısıtla (role: "past_due"), son email
15Hesap askıya al → abonelik iptali + çıkış emaili
30Geri kazanım emaili — indirim teklifi
// lib/dunning.ts export function getDunningAction(failedAttempts: number): "retry" | "warn" | "suspend" { if (failedAttempts <= 1) return "retry" if (failedAttempts <= 3) return "warn" return "suspend" }

Vergi Notu

SaaS geliri Türkiye'de vergilendirilir. Şirket türü (şahıs, ltd), yurt içi/yurt dışı müşteri oranı ve ciro gibi faktörler vergi yükümlülüklerinizi doğrudan etkiler.

Önemli: Abonelik modeli kurduğunuzda bir Serbest Muhasebeci Mali Müşavir (SMMM) ile çalışmanızı kesinlikle öneririz. KDV mükellefiyet eşiği, e-SMM veya e-fatura zorunluluğu ve yurt dışı müşteri faturalaması gibi konularda profesyonel destek almanız hem yasal hem de finansal açıdan kritiktir. Vergisel konularda ilgili resmi kaynaklara (GİB) ve uzmanlarına danışın.


Ortam Değişkenleri

# .env.local # PayTR (Abonelik Sanal POS bilgileri) PAYTR_MERCHANT_ID= PAYTR_MERCHANT_KEY= PAYTR_MERCHANT_SALT= # Abonelik fiyatı (kuruş cinsinden — örn: 29900 = 299 TL) PLAN_PRICE_KURUS= # Better Auth BETTER_AUTH_SECRET= # En az 32 karakter rastgele string # Database DATABASE_URL= # Resend RESEND_API_KEY= # Uygulama NEXT_PUBLIC_URL=https://www.domain.com # Cron güvenliği CRON_SECRET=

Production Kontrol Listesi

  • PayTR abonelik Sanal POS başvurusu yapıldı ve onaylandı
  • test_mode=0 production'da
  • Postback URL HTTPS ve kamuya açık (ngrok değil)
  • PayTR postback URL Merchant Panel'e girildi
  • HMAC doğrulaması postback'te aktif
  • utoken ve ctoken şifreli olarak saklanıyor (env encryption)
  • Cron job güvenlik token'ı aktif
  • Dunning email akışları test edildi
  • Trial bitişi cron'u aktif
  • Hata loglama (Sentry veya benzeri) kurulu
  • KVKK için gizlilik politikası sayfası mevcut
  • Abonelik iptali müşteri tarafından yapılabiliyor

Sıkça Sorulan Sorular

PayTR tekrarlayan ödeme için ayrı başvuru şart mı?

Evet. Standard PayTR Sanal POS ile kart saklama özelliği gelmiyor. PayTR'dan "Abonelik / Kart Saklama" özellikli ayrı bir Sanal POS başvurusu yapmanız gerekiyor. Başvuru süreci ve şartlar için doğrudan PayTR ile iletişime geçin.

PayTR'da utoken ve ctoken arasındaki fark nedir?

utoken ilk başarılı ödeme sonrası PayTR postback'inde gelen kullanıcıya özgü token'dır. ctoken ise Kart Listesi API'si üzerinden alınan, o kullanıcının kayıtlı kartına özgü token'dır. Tekrarlayan ödeme için her ikisi de gereklidir.

PayTR yerine İyzico abonelik ürünü kullanmalı mıyım?

İyzico abonelik ürünü özellikle otomatik çekim yönetimini platform üzerine bırakmak isteyenler için uygun. Kendi cron job altyapısı kurmak istemiyorsanız tercih edilebilir. Güncel fiyatlandırma ve özellikler için İyzico'nun resmi belgelerine bakın.

Better Auth ile "trial" rolü nasıl uygulanır?

Better Auth'ın admin plugin'i kullanıcı rollerini yönetmenizi sağlar. Kayıt sırasında role: "trial" atayabilir, başarılı ödeme postback'inde auth.api.setRole ile güncelleyebilirsiniz. proxy.ts üzerinden rol bazlı route koruması yapabilirsiniz.

Müşteriye fatura nasıl kesilir?

Türkiye'de B2B müşterilere e-SMM (serbest meslek erbabıysanız) veya e-Fatura/e-Arşiv (şirketliyseniz) kesmeniz gerekmektedir. Bu konuda bir SMMM ile çalışmanızı öneririz.

Vercel Hobby planı ile cron job çalışır mı?

Kısıtlı. Vercel Hobby planında cron job en fazla günde bir kez çalışır. Abonelik ödeme takibi için Vercel Pro planı veya Upstash QStash gibi harici cron servislerini değerlendirin.

Landing page için kaç kelime yeterli?

SEO için minimum 800 kelime, dönüşüm için minimum ise gerekli olan bölümlerin tümünün (hero, problem/çözüm, özellikler, fiyatlandırma, SSS) eksiksiz olmasıdır. Kelime uzunluğundan çok içerik kalitesi belirleyicidir.


Sonuç

Türkiye'de Micro-SaaS kurmak için teknik altyapı eksiksiz mevcut. Next.js 16 + Better Auth + Drizzle + PayTR + Resend kombinasyonu, Türk pazarının gerekliliklerini (TCMB lisanslı ödeme, Türkçe email, KOBİ odaklı stack) karşılayan bir çözüm sunuyor.

Başarının sırrı teknik altyapıda değil, doğru problemi bulmakta ve ilk 10 müşteriye aşırı değer sunmakta gizli. Stack'i kurun, ama daha önce problemi doğrulayın.

Bu stack'i kendi projelerimizde de kullanıyoruz — sorularınız için iletişime geçin.


İlgili Yazılar:

Paylaş: