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.

Ö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.
| Katman | Seçim | Neden |
|---|---|---|
| Framework | Next.js 16 (App Router) | Full-stack, Vercel deploy, SSR/SSG |
| Auth | Better Auth v1.5.5 | Plugin sistemi, magic link, ücretsiz |
| Veritabanı | Drizzle ORM + PostgreSQL | TypeScript-first, düşük overhead |
| Ödeme | PayTR (kart saklama) | Türkiye TCMB lisanslı, hızlı onboarding |
| Resend + React Email | Ücretsiz başlangıç, Türkçe karakter desteği | |
| Deployment | Vercel | Edge network, preview deploy |
| Runtime | Bun | Hı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:
- Kullanıcı kayıt olduğunda
role: "trial"atanır - İlk başarılı ödeme postback'inde
role: "premium"olarak güncellenir - Ödeme başarısız olduğunda
role: "trial"veyarole: "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:
- İlk ödeme — iframe üzerinden, 3DS ile. Postback'te
utoken(kullanıcı token'ı) gelir. - Sonraki ödemeler — Kart Listesi API'sinden
ctokenalı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.successvesubscription.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?
| Kriter | PayTR (kart saklama) | İyzico (abonelik ürünü) |
|---|---|---|
| Ödeme zamanlaması | Siz tetiklersiniz | İyzico otomatik çeker |
| Kontrol | Tam kontrol | Platform yönetiyor |
| Özelleştirme | Yüksek | Orta |
| Ek ücret | Ayrı POS başvurusu | Abonelik modülü ücreti |
| Webhook | Var (callback URL) | Var (subscription events) |
| Türkiye pazarı | Yaygın | Yaygı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:
- Hero — Ne yapıyorsunuz, kim için, neden şimdi (3 saniyede anlaşılsın)
- Problem → Çözüm — Kullandığınız mevcut yöntem vs sizin çözümünüz
- Özellikler — Maksimum 6 özellik, her biri bir faydaya bağlı
- Sosyal kanıt — Müşteri yorumları veya vaka çalışmaları
- Fiyatlandırma — Şeffaf, karşılaştırmalı plan tablosu
- SSS — En sık sorulan 5-8 soru (featured snippet için)
- 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ün | Eylem |
|---|---|
| 0 | Ödeme başarısız → hemen email gönder |
| 3 | Tekrar dene → başarısız → "kart güncelle" emaili |
| 7 | Son uyarı emaili — erişim kısıtlanacak |
| 10 | Erişimi kısıtla (role: "past_due"), son email |
| 15 | Hesap askıya al → abonelik iptali + çıkış emaili |
| 30 | Geri 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=0production'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:

