15.03.2026 10 мин чтения

SaaS с нуля: подписки, Stripe и Next.js

Построить SaaS — это не только написать фичи. Это аутентификация, биллинг, роли, webhooks, мультитенантность. Вот архитектура, которую я использую в каждом SaaS-проекте на Next.js.

Стек

Архитектура базы данных

Минимальная схема для 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*'],
}

Чеклист перед запуском

Эта архитектура масштабируется от MVP до тысяч платящих пользователей. Stripe берёт на себя compliance, PCI DSS и обработку платежей — вы фокусируетесь на продукте.

← Предыдущая Все статьи →