Next.js SaaS Starter
Uma stack inicial SaaS completa - Next.js 15 + React 19 + Auth.js + Prisma + Stripe + Tailwind + shadcn/ui - a configuração completa pronta para produção na qual você pode construir um produto real.
Busque em todas as páginas da documentação
Uma stack inicial SaaS completa - Next.js 15 + React 19 + Auth.js + Prisma + Stripe + Tailwind + shadcn/ui - a configuração completa pronta para produção na qual você pode construir um produto real.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida - pronto para copiar e colar.
# 1. Crie um scaffold do Next.js 15 com Tailwind e TypeScript
npx create-next-app@latest my-saas --typescript --tailwind --app --turbopack
cd my-saas
# 2. Adicione shadcn/ui
npx shadcn@latest init
npx shadcn@latest add button card input form dialog
# 3. Instale o restante da stack
npm install next-auth@beta @auth/prisma-adapter
npm install @prisma/client
npm install -D prisma
npm install stripe @stripe/stripe-js
npm install zod react-hook-form @hookform/resolvers
# 4. Inicialize o Prisma (Postgres)
npx prisma init --datasource-provider postgresql
# 5. Gere o segredo do Auth.js
npx auth secret// package.json - o conjunto de dependências pronto para produção
\{
"dependencies": \{
"@auth/prisma-adapter": "^2.7.0",
"@hookform/resolvers": "^3.9.0",
"@prisma/client": "^6.0.0",
"@stripe/stripe-js": "^5.0.0",
"next": "15.1.0",
"next-auth": "5.0.0-beta.25",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.0",
"stripe": "^17.4.0",
"zod": "^3.24.0"
\},
"devDependencies": \{
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"prisma": "^6.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3"
\}
\}Quando usar isso: Quando você estiver iniciando um produto SaaS real e quiser a stack canônica - banco de dados, autenticação, pagamentos, kit de UI e validação - interligados corretamente desde o primeiro dia.
my-saas/
app/
(auth)/
login/page.tsx
register/page.tsx
(dashboard)/
layout.tsx
dashboard/page.tsx
billing/page.tsx
api/
auth/[...nextauth]/route.ts
webhooks/
stripe/route.ts
lib/
prisma.ts
stripe.ts
auth.ts
validations.ts
prisma/
schema.prisma
auth.ts
middleware.ts
.env.local
// prisma/schema.prisma
generator client \{
provider = "prisma-client-js"
\}
datasource db \{
provider = "postgresql"
url = env("DATABASE_URL")
\}
model User \{
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
stripeCustomerId String? @unique
accounts Account[]
sessions Session[]
subscription Subscription?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
\}
model Account \{
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
\}
model Session \{
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
\}
model Subscription \{
id String @id @default(cuid())
userId String @unique
stripeSubscriptionId String @unique
stripePriceId String
status String // active, trialing, past_due, canceled, etc.
currentPeriodEnd DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
\}// lib/prisma.ts - singleton para sobreviver à recarga a quente
import \{ PrismaClient \} from "@prisma/client";
const globalForPrisma = globalThis as unknown as \{
prisma: PrismaClient | undefined;
\};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient(\{
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
\});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, \{
apiVersion: "2024-12-18.acacia",
typescript: true,
\});// auth.ts - Configuração do Auth.js v5 (beta)
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import \{ PrismaAdapter \} from "@auth/prisma-adapter";
import \{ prisma \} from "@/lib/prisma";
import \{ stripe \} from "@/lib/stripe";
export const \{ handlers, auth, signIn, signOut \} = NextAuth(\{
adapter: PrismaAdapter(prisma),
session: \{ strategy: "database" \},
providers: [GitHub],
events: \{
async createUser(\{ user \}) \{
// Crie um cliente Stripe no momento em que o usuário se inscreve
const customer = await stripe.customers.create(\{
email: user.email ?? undefined,
name: user.name ?? undefined,
metadata: \{ userId: user.id! \},
\});
await prisma.user.update(\{
where: \{ id: user.id \},
data: \{ stripeCustomerId: customer.id \},
\});
\},
\},
callbacks: \{
async session(\{ session, user \}) \{
const sub = await prisma.subscription.findUnique(\{ where: \{ userId: user.id \} \});
session.user.id = user.id;
session.user.stripeCustomerId = (user as any).stripeCustomerId ?? null;
session.user.subscriptionStatus = sub?.status ?? "none";
return session;
\},
\},
\});// app/api/auth/[...nextauth]/route.ts
export \{ GET, POST \} from "@/auth";// lib/validations.ts
import \{ z \} from "zod";
export const checkoutSchema = z.object(\{
priceId: z.string().startsWith("price_"),
\});
export type CheckoutInput = z.infer<typeof checkoutSchema>;// app/(dashboard)/billing/actions.ts - Server Action: iniciar uma Sessão de Checkout
"use server";
import \{ redirect \} from "next/navigation";
import \{ auth \} from "@/auth";
import \{ stripe \} from "@/lib/stripe";
import \{ checkoutSchema \} from "@/lib/validations";
export async function createCheckoutSession(formData: FormData) \{
const session = await auth();
if (!session?.user?.stripeCustomerId) throw new Error("Não autenticado");
const parsed = checkoutSchema.parse(\{ priceId: formData.get("priceId") \});
const checkout = await stripe.checkout.sessions.create(\{
customer: session.user.stripeCustomerId,
mode: "subscription",
line_items: [\{ price: parsed.priceId, quantity: 1 \}],
success_url: `$\{process.env.APP_URL\}/dashboard?checkout=success`,
cancel_url: `$\{process.env.APP_URL\}/billing?checkout=cancelled`,
metadata: \{ userId: session.user.id \},
\});
if (!checkout.url) throw new Error("Nenhuma URL de checkout");
redirect(checkout.url);
\}// app/api/webhooks/stripe/route.ts - Runtime Node, NÃO edge
import \{ NextRequest, NextResponse \} from "next/server";
import Stripe from "stripe";
import \{ stripe \} from "@/lib/stripe";
import \{ prisma \} from "@/lib/prisma";
export const runtime = "nodejs"; // crítico - webhooks precisam de crypto do Node
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) \{
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return new NextResponse("Sem assinatura", \{ status: 400 \});
let event: Stripe.Event;
try \{
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
\} catch (err) \{
return new NextResponse(`Erro no webhook: ${(err as Error).message}`, \{ status: 400 \});
\}
switch (event.type) \{
case "checkout.session.completed":
case "customer.subscription.updated":
case "customer.subscription.created": \{
const sub = event.data.object as Stripe.Subscription;
const userId = (sub.metadata?.userId as string) ?? null;
if (!userId) break;
await prisma.subscription.upsert(\{
where: \{ userId \},
create: \{
userId,
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0]!.price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
\},
update: \{
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0]!.price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
\},
\});
break;
\}
case "customer.subscription.deleted": \{
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.updateMany(\{
where: \{ stripeSubscriptionId: sub.id \},
data: \{ status: "canceled" \},
\});
break;
\}
\}
return NextResponse.json(\{ received: true \});
\}// middleware.ts - protege o dashboard
import \{ auth \} from "@/auth";
export default auth((req) => \{
if (!req.auth && req.nextUrl.pathname.startsWith("/dashboard")) \{
const url = new URL("/login", req.url);
return Response.redirect(url);
\}
\});
export const config = \{ matcher: ["/dashboard/:path*", "/billing/:path*"] \};# .env.local - variáveis de ambiente necessárias
DATABASE_URL="postgresql://user:pass@localhost:5432/mysaas"
AUTH_SECRET="gerado por npx-auth-secret"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
APP_URL="http://localhost:3000"O que isso demonstra:
redirect() para o Checkoutevents.createUser do Auth.js cria um cliente Stripe no momento da inscrição para que stripeCustomerId esteja sempre presentesession.user com informações de cobrança para controle de acesso na UIauth(), handlers, signIn, signOut de um único arquivo de configuração. handlers é reexportado por uma rota catch-all em app/api/auth/[...nextauth]/route.ts.session: \{ strategy: "database" \} significa que as sessões vivem no Postgres, não em JWTs.events.createUser cria um cliente Stripe e grava stripeCustomerId de volta em User. Agora, cada checkout futuro usa o mesmo registro de cliente, o que é crucial para o gerenciamento de assinaturas.redirect() envia o navegador para o Stripe./api/webhooks/stripe. O manipulador verifica a assinatura usando o corpo bruto, analisa o evento e faz um upsert na linha Subscription.session.user.subscriptionStatus reflita o estado de cobrança mais recente sem buscas adicionais do cliente.Use um starter existente em vez de construir do zero:
pnpm create next-app --example next-saas-startercreate-t3-app) - Next.js + tRPC + Prisma + NextAuth, adicione o Stripe manualmenteSuporte a equipe / organização:
model Organization \{
id String @id @default(cuid())
name String
members Member[]
subscription Subscription?
\}
model Member \{
id String @id @default(cuid())
userId String
organizationId String
role String // owner | admin | member
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([userId, organizationId])
\}Mova stripeCustomerId e Subscription para Organization para que a cobrança seja por espaço de trabalho.
Segurança em nível de linha com Supabase: use políticas @supabase/ssr + RLS em vez de Prisma para dados que você deseja proteger em nível de banco de dados.
Padrões de multi-tenancy: roteamento de subdomínio ([tenant].app.com) via reescrita de middleware, ou baseado em caminho (/org/[slug]). Armazene tenantId em cada linha.
E-mail com Resend:
import \{ Resend \} from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send(\{ from: "hi@app.com", to: user.email, subject: "Welcome", react: <Welcome /> \});Jobs em segundo plano com Inngest: descarregue efeitos colaterais de webhook (envio de e-mails de boas-vindas, provisionamento de recursos) do manipulador de webhook para que o Stripe receba um 200 rapidamente.
// types/next-auth.d.ts - aumente o tipo Session
import \{ DefaultSession \} from "next-auth";
declare module "next-auth" \{
interface Session \{
user: \{
id: string;
stripeCustomerId: string | null;
subscriptionStatus: "active" | "trialing" | "past_due" | "canceled" | "none";
\} & DefaultSession["user"];
\}
\}// Estreitamento de evento de webhook Stripe tipado
import type Stripe from "stripe";
function isSubscriptionEvent(
event: Stripe.Event,
): event is Stripe.Event & \{ data: \{ object: Stripe.Subscription \} \} \{
return event.type.startsWith("customer.subscription.");
\}// Esquema Zod → tipo de entrada do formulário em uma linha
import \{ z \} from "zod";
const pricingFormSchema = z.object(\{ priceId: z.string().startsWith("price_") \});
type PricingFormValues = z.infer<typeof pricingFormSchema>;As assinaturas de webhook do Stripe devem ser verificadas - chamar JSON.parse(await req.text()) sem constructEvent significa que qualquer pessoa que adivinhar seu URL pode modificar seu banco de dados. Correção: sempre chame stripe.webhooks.constructEvent(rawBody, signature, secret) e passe o corpo bruto como string, não como um objeto analisado.
O endpoint de webhook não pode usar o runtime edge - o SDK Node do Stripe usa módulos crypto que não existem na edge. A verificação de assinatura lançará crypto.createHmac is not a function. Correção: defina export const runtime = "nodejs" na rota do webhook.
O cliente Prisma deve ser um singleton em desenvolvimento - sem ele, cada recarga a quente gera um novo cliente e o Postgres eventualmente recusa conexões com "muitos clientes". Correção: use o padrão singleton globalThis em lib/prisma.ts.
A sincronização do ID do cliente Stripe com Auth.js deve ocorrer no momento da inscrição, não no checkout - se você criar o cliente Stripe de forma preguiçosa no primeiro checkout, você atingirá condições de corrida (clique duplo no botão de upgrade, obtenha dois clientes). Correção: use events.createUser para provisionar o cliente atomicamente quando o usuário for criado.
Variáveis de ambiente para chaves Stripe de desenvolvimento vs. produção - confirmar chaves de teste em .env.production (ou esquecer de trocar o segredo do webhook) falhará silenciosamente na verificação de assinatura em produção. Correção: use contas/modos Stripe separados, endpoints de webhook separados e variáveis de ambiente separadas para cada ambiente. Nunca reutilize STRIPE_WEBHOOK_SECRET entre ambientes.
As alterações no status da assinatura são assíncronas - um usuário paga, é redirecionado para success_url, e seu aplicativo mostra felizmente "Recursos Pro" - mas o webhook ainda não chegou, então o banco de dados ainda diz status: "none". Correção: (a) espere pelo webhook antes de conceder acesso (faça polling na página de sucesso), ou (b) confie otimisticamente no resultado da Sessão de Checkout e reconcilie via webhook em segundos.
As Server Actions devem proteger contra chamadores não autenticados - "use server" não verifica a autenticação para você. Cada ação deve chamar auth() e lançar um erro se a sessão estiver ausente. Correção: envolva as ações em um helper requireUser().
Middleware e auth() juntos - chamar auth() dentro do middleware na edge funciona, mas chamar Prisma do middleware não (Prisma não é compatível com edge). Correção: mantenha o middleware apenas para verificações de sessão; faça buscas no banco de dados em manipuladores de página/rota.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| Shipixen | Você quer um starter polido e opinativo gerado a partir de um assistente | Você quer possuir cada linha de código |
| Makerkit (pago) | Você quer um template SaaS pronto para produção com equipes, cobrança e blog pré-construídos | Restrições orçamentárias ou requisito totalmente de código aberto |
| Vercel Commerce | Você está construindo uma loja de e-commerce, não um SaaS de assinatura | Você precisa de autenticação + assinaturas por usuário |
| Vercel Next.js SaaS Starter | Você quer a referência canônica da Vercel - Next.js + Postgres + Stripe + Drizzle | Você prefere Prisma em vez de Drizzle |
| Template Next.js da Railway | Você quer o Postgres hospedado na Railway configurado automaticamente | Você está implantando no Vercel ou Cloudflare |
| T3 Stack + Stripe manual | Você quer tRPC e forte segurança de tipo na fronteira cliente/servidor | Você prefere REST ou Server Actions em vez de tRPC |
Porque stripe.webhooks.constructEvent depende do módulo crypto do Node para verificar assinaturas HMAC, que o runtime edge não fornece. Executar na edge lançará um erro no primeiro webhook.
Isso garante que cada usuário tenha exatamente um stripeCustomerId desde o momento em que se inscreve, eliminando condições de corrida e registros de clientes duplicados quando os usuários clicam em "Upgrade" várias vezes ou atualizam no meio do checkout.
A recarga a quente do Next.js reavalia os módulos a cada alteração de arquivo em desenvolvimento. Sem o singleton, cada recarga cria um novo PrismaClient, cada um abrindo seu próprio pool. O Postgres rapidamente atinge seu limite de conexões e lança "muitos clientes".
Instale a CLI do Stripe, execute stripe listen --forward-to localhost:3000/api/webhooks/stripe, copie o whsec_... impresso para STRIPE_WEBHOOK_SECRET e, em seguida, acione eventos com stripe trigger checkout.session.completed.
database armazena sessões em seu banco de dados via adapter - você pode revogar uma única sessão do servidor. jwt armazena tudo em um cookie assinado - sem ida e volta ao banco de dados, mas a revogação requer a alteração de AUTH_SECRET, o que destrói todas as sessões.
Para que cada página e Server Action que chama auth() veja automaticamente o status de cobrança mais recente sem precisar consultar o Prisma separadamente. A desvantagem é uma consulta extra por solicitação; armazene em cache se necessário.
O Stripe retenta webhooks que não retornam 2xx em 30 segundos. Torne o manipulador idempotente: use upsert com chave em stripeSubscriptionId e retorne 200 imediatamente, descarregando trabalho lento para um job em segundo plano.
O webhook ainda não havia chegado quando o redirecionamento ocorreu, então subscriptionStatus ainda era none. Ou faça polling na página de sucesso até que o banco de dados seja atualizado, ou busque a Sessão de Checkout diretamente com stripe.checkout.sessions.retrieve(id) e confie no payment_status.
Crie types/next-auth.d.ts e use declare module "next-auth" para aumentar a interface Session. Inclua o arquivo em seu tsconfig.json via "include". O Auth.js v5 lê a ampliação de módulo em tempo de compilação.
Escreva um guarda de tipo definido pelo usuário:
function isSubscriptionEvent(
e: Stripe.Event,
): e is Stripe.Event & \{ data: \{ object: Stripe.Subscription \} \} \{
return e.type.startsWith("customer.subscription.");
\}Então if (isSubscriptionEvent(event)) \{ ... \} lhe dará um event.data.object tipado dentro do bloco.
Em User se a cobrança for por usuário (planos pessoais). Em Organization se a cobrança for por espaço de trabalho (planos de equipe) - que é o modelo SaaS normal. Você pode começar em User e migrar mais tarde, mas é mais fácil projetar para organizações desde o início.
Sim. Auth.js tem @auth/drizzle-adapter e um adapter Kysely. O restante desta receita (Stripe, Server Actions, manipulação de webhook) é independente de ORM. Drizzle é mais leve na edge; Prisma tem melhor DX e um ecossistema mais rico.
Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥