SaaS с нуля: подписки, Stripe и Next.js
Построить SaaS — это не только написать фичи. Это аутентификация, биллинг, роли, webhooks, мультитенантность. Вот архитектура, которую я использую в каждом SaaS-проекте на Next.js.
Стек
- Next.js (App Router) — фронтенд + API routes
- NextAuth.js / Auth.js — аутентификация (OAuth, magic links)
- PostgreSQL + Prisma — база данных и ORM
- Stripe — подписки и платежи
- Vercel — хостинг и edge functions
Архитектура базы данных
Минимальная схема для SaaS с подписками:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
teamId String?
team Team? @relation(fields: [teamId], references: [id])
createdAt DateTime @default(now())
}
model Team {
id String @id @default(cuid())
name String
stripeCustomerId String? @unique
plan Plan @default(FREE)
members User[]
createdAt DateTime @default(now())
}
enum Role { USER ADMIN OWNER }
enum Plan { FREE PRO ENTERPRISE }
Ключевой принцип: биллинг привязан к Team, не к User. Это позволяет одному аккаунту менеджера оплачивать подписку для всей команды.
Stripe Checkout
Не создавайте свою форму оплаты. Stripe Checkout — это hosted page с поддержкой всех способов оплаты, 3D Secure и SCA compliance из коробки.
// app/api/checkout/route.ts
import { stripe } from '@/lib/stripe'
import { auth } from '@/lib/auth'
export async function POST() {
const session = await auth()
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const checkout = await stripe.checkout.sessions.create({
customer: session.user.team.stripeCustomerId,
mode: 'subscription',
line_items: [{
price: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
}],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
})
return Response.json({ url: checkout.url })
}
Webhooks: источник правды
Stripe Checkout перенаправляет пользователя на success_url, но это не подтверждение оплаты. Пользователь мог закрыть вкладку. Единственный надёжный источник — webhook.
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
await db.team.update({
where: { stripeCustomerId: session.customer },
data: { plan: 'PRO' },
})
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object
await db.team.update({
where: { stripeCustomerId: sub.customer },
data: { plan: 'FREE' },
})
break
}
}
return Response.json({ received: true })
}
Правило: никогда не обновляйте план на клиенте или через success_url redirect. Только через webhook.
Middleware: защита маршрутов
Next.js middleware позволяет проверять доступ на edge-уровне, до рендеринга страницы:
// middleware.ts
import { auth } from '@/lib/auth'
export default auth((req) => {
const { pathname } = req.nextUrl
// Защита дашборда
if (pathname.startsWith('/dashboard') && !req.auth) {
return Response.redirect(new URL('/login', req.url))
}
// Pro-only фичи
if (pathname.startsWith('/dashboard/analytics')
&& req.auth?.user.team.plan === 'FREE') {
return Response.redirect(new URL('/pricing', req.url))
}
})
export const config = {
matcher: ['/dashboard/:path*'],
}
Чеклист перед запуском
- Stripe Test Mode — все платежи тестируйте с карточкой
4242 4242 4242 4242 - Webhook endpoint — зарегистрирован в Stripe Dashboard и на Vercel
- Customer portal — дайте пользователям управлять подпиской самостоятельно
- Usage limits — проверяйте лимиты плана в API routes, не только на фронтенде
- Idempotency — webhook может прийти дважды, обработка должна быть идемпотентной
Эта архитектура масштабируется от MVP до тысяч платящих пользователей. Stripe берёт на себя compliance, PCI DSS и обработку платежей — вы фокусируетесь на продукте.