Tabela de preços dinâmica com Stripe e Next.js
Como implementar tabela de preços multitier no Next.js com Stripe Billing, webhooks e upgrade/downgrade. Tutorial prático para micro-SaaS solo.
Por Vitor Morais
Fundador do MochaLabz ·
Implementar uma tabela de preços dinâmica com Stripe e Next.js é uma das primeiras decisões técnicas que fazem ou quebram um micro-SaaS em produção. A ideia central é simples: cada plano vive como um Price no Stripe, seu Next.js busca esses preços via API e renderiza os cards em tempo real — sem hardcode, sem deploy toda vez que você ajusta valor. Este guia cobre o fluxo completo: criação de produtos, Checkout Session, webhooks de pagamento e cancelamento, e controle de acesso baseado em assinatura.
Por que não hardcodar os preços no frontend
A tentação de escrever R$ 49/mês direto no JSX é real — funciona no MVP, quebra rápido. Quando você muda o preço no Stripe (cupom de lançamento, ajuste por câmbio, novo tier), o valor exibido diverge do cobrado. Cliente vê R$ 49, Stripe cobra R$ 59, suporte explode.
A abordagem correta: Stripe é a fonte da verdade. Você cria os preços no dashboard ou via API, adiciona metadados customizados (nome do plano, features, destaque), e busca esses dados em tempo de build — ou em runtime com cache curto. O componente de pricing recebe os dados e renderiza. Mudar um preço no Stripe reflete no site sem deploy.
- Produto no Stripe = seu SaaS (ex.: "MeuSaaS Pro")
- Price = configuração de cobrança (mensal, anual, por uso)
- Customer = usuário cadastrado, criado no primeiro checkout
- Subscription = vínculo ativo entre Customer e Price
- Webhook = Stripe notificando seu backend sobre eventos
Estrutura de produtos e preços no Stripe
Para um SaaS indie típico com três planos (Gratuito, Pro, Business), você cria dois produtos no Stripe (Free não precisa de produto pago) e dois preços recorrentes. Adicione metadados para carregar features sem banco de dados extra:
scripts/seed-stripe.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
async function seed() {
// Produto Pro
const pro = await stripe.products.create({
name: 'Pro',
metadata: {
features: JSON.stringify([
'Até 10 projetos',
'API com rate limit generoso',
'Suporte por email',
]),
highlight: 'false',
},
});
await stripe.prices.create({
product: pro.id,
unit_amount: 4900, // centavos — R$ 49,00
currency: 'brl',
recurring: { interval: 'month' },
});
// Produto Business
const biz = await stripe.products.create({
name: 'Business',
metadata: {
features: JSON.stringify([
'Projetos ilimitados',
'API sem rate limit',
'Suporte prioritário + Slack',
]),
highlight: 'true',
},
});
await stripe.prices.create({
product: biz.id,
unit_amount: 14900, // R$ 149,00
currency: 'brl',
recurring: { interval: 'month' },
});
console.log('Stripe seed concluído.');
}
seed();unit_amount sempre em centavos
O Stripe usa a menor unidade da moeda. Para BRL, unit_amount: 4900 = R$ 49,00. Nunca passe o valor em reais diretamente — vai cobrar R$ 0,49 e você não vai perceber até o próximo relatório.
Buscando preços dinamicamente no Next.js App Router
Com App Router, a página de pricing pode ser um Server Component que busca os dados diretamente, sem API route intermediária. Use next: { revalidate } para cachear por algumas horas — preços mudam raramente e você não quer latência no carregamento.
app/precos/page.tsx
import Stripe from 'stripe';
import { PricingCard } from '@/components/pricing-card';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export const revalidate = 3600; // 1 hora de cache
export default async function PricingPage() {
const prices = await stripe.prices.list({
active: true,
expand: ['data.product'],
type: 'recurring',
});
const plans = prices.data
.filter((p) => (p.product as Stripe.Product).active)
.map((price) => {
const product = price.product as Stripe.Product;
return {
priceId: price.id,
name: product.name,
amount: price.unit_amount ?? 0,
currency: price.currency,
interval: price.recurring?.interval ?? 'month',
features: JSON.parse(product.metadata.features ?? '[]') as string[],
highlight: product.metadata.highlight === 'true',
};
})
.sort((a, b) => a.amount - b.amount);
return (
<main className="grid gap-6 md:grid-cols-3 p-8">
<PricingCard
key="free"
name="Gratuito"
amount={0}
currency="brl"
interval="month"
features={['1 projeto', 'API com limite diário', 'Sem suporte']}
priceId={null}
/>
{plans.map((plan) => (
<PricingCard key={plan.priceId} {...plan} />
))}
</main>
);
}O componente PricingCard recebe priceId e, ao clicar em "Assinar", chama uma API route que cria a CheckoutSession. O plano gratuito tem priceId: null — ao clicar, apenas cria conta sem sessão de pagamento.
Checkout Session: do clique ao pagamento
A API route de checkout recebe priceId e userId, busca ou cria o Customer no Stripe, e retorna a URL de redirecionamento. Armazene o stripeCustomerId no seu banco (Supabase, por exemplo) para reusá-lo em toda sessão futura — evita duplicatas e facilita consultar assinaturas.
app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: NextRequest) {
const { priceId } = await req.json();
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Busca ou cria customer no Stripe
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single();
let customerId = profile?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { supabase_user_id: user.id },
});
customerId = customer.id;
await supabase
.from('profiles')
.update({ stripe_customer_id: customerId })
.eq('id', user.id);
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/app?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/precos`,
allow_promotion_codes: true,
});
return NextResponse.json({ url: session.url });
}Nunca crie Customer duplicado
Cada stripe.customers.create sem checar se já existe gera duplicata — e você perde histórico de cobrança. Sempre salve o stripe_customer_id no seu banco na primeira criação e reutilize. Se o perfil não tiver o ID, busque no Stripe por email antes de criar: stripe.customers.list({ email }) como fallback.
Webhooks: sincronizando assinaturas com o banco
O webhook é onde a maioria dos bugs de billing acontece em projetos indie. O Stripe envia eventos para sua API route e você atualiza o plano do usuário no banco. Os eventos que você precisa tratar para um SaaS básico:
checkout.session.completed— pagamento aprovado, ativa planocustomer.subscription.updated— mudança de plano (upgrade/downgrade)customer.subscription.deleted— cancelamento, volta ao Freeinvoice.payment_failed— falha no cartão, avisa o usuário
app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createAdminClient } from '@/lib/supabase/admin';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const supabase = createAdminClient();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.CheckoutSession;
const sub = await stripe.subscriptions.retrieve(
session.subscription as string,
{ expand: ['items.data.price.product'] }
);
const product = sub.items.data[0].price.product as Stripe.Product;
await supabase
.from('profiles')
.update({
plan: product.name.toLowerCase(), // 'pro' | 'business'
subscription_id: sub.id,
subscription_status: sub.status,
})
.eq('stripe_customer_id', session.customer as string);
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const product = await stripe.products.retrieve(
(sub.items.data[0].price.product as string)
);
await supabase
.from('profiles')
.update({
plan: product.name.toLowerCase(),
subscription_status: sub.status,
})
.eq('subscription_id', sub.id);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await supabase
.from('profiles')
.update({ plan: 'free', subscription_status: 'canceled', subscription_id: null })
.eq('subscription_id', sub.id);
break;
}
}
return NextResponse.json({ received: true });
}Teste webhooks localmente com Stripe CLI
Execute stripe listen --forward-to localhost:3000/api/webhooks/stripe para receber eventos reais em desenvolvimento. O CLI gera um STRIPE_WEBHOOK_SECRET temporário — copie para .env.local. Sem isso, constructEvent sempre falha e você vai perder horas debugando.
Controle de acesso baseado em plano
Com o campo plan atualizado no Supabase por webhook, o controle de acesso fica simples: leia o perfil do usuário logado e bloqueie features pelo valor. Em Server Components, faça isso no servidor — nunca no cliente, onde o usuário pode manipular o estado.
lib/access.ts
type Plan = 'free' | 'pro' | 'business';
const PLAN_LIMITS: Record<Plan, { maxProjects: number; apiRateLimit: number }> = {
free: { maxProjects: 1, apiRateLimit: 100 },
pro: { maxProjects: 10, apiRateLimit: 5000 },
business: { maxProjects: Infinity, apiRateLimit: Infinity },
};
export function getLimits(plan: Plan) {
return PLAN_LIMITS[plan] ?? PLAN_LIMITS.free;
}
export function canCreateProject(plan: Plan, currentCount: number): boolean {
return currentCount < getLimits(plan).maxProjects;
}Use getLimits nas API routes que criam recursos e em Server Actions. No middleware do Next.js, você pode checar subscription_status para redirecionar usuários com pagamento falho para uma página de atualização de cartão — antes de deixá-los acessar o app. Isso previne churn silencioso por cartão vencido. Veja como essa lógica se encaixa com a estratégia de precificação de SaaS MVP para os primeiros clientes.
Upgrade e downgrade: o fluxo certo sem tela de checkout
Para usuários já assinantes, não abra uma nova Checkout Session — use o Customer Portal do Stripe ou troque o price diretamente via API. O portal gerencia cancelamento, upgrade e atualização de cartão com zero código extra da sua parte.
app/api/billing-portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: NextRequest) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single();
const portalSession = await stripe.billingPortal.sessions.create({
customer: profile!.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/app/configuracoes`,
});
return NextResponse.json({ url: portalSession.url });
}Ative o Customer Portal no dashboard do Stripe em Settings → Billing → Customer portal. Configure quais planos são visíveis para upgrade/downgrade e se permite cancelamento imediato ou só ao fim do período. O Stripe lida com proratação automaticamente — você não precisa calcular créditos. Esse fluxo é compatível com o modelo de billing por uso descrito em como cobrar pelo uso de IA no Stripe se você quiser adicionar uma dimension de consumo depois.
| Cenário | Checkout Session | Customer Portal |
|---|---|---|
| Novo usuário assinando | ✅ Ideal | ❌ Não disponível |
| Upgrade de plano existente | ⚠️ Cria sessão nova (duplica customer) | ✅ Ideal |
| Atualizar cartão de crédito | ❌ Não resolve | ✅ Fluxo nativo |
| Cancelar assinatura | ❌ Não suportado | ✅ Configurável |
| Aplicar cupom retroativo | ❌ Limitado | ✅ Via portal |
Schema mínimo no Supabase para billing
Você não precisa de tabela separada de assinaturas para começar. Adicione colunas na tabela profiles existente — menos joins, menos complexidade. Quando seu SaaS tiver múltiplos planos por organização ou seats, aí faz sentido normalizar.
migration: adicionar billing ao profiles
-- Execute no SQL Editor do Supabase ou via migration
ALTER TABLE profiles
ADD COLUMN stripe_customer_id TEXT UNIQUE,
ADD COLUMN subscription_id TEXT,
ADD COLUMN subscription_status TEXT DEFAULT 'inactive',
ADD COLUMN plan TEXT NOT NULL DEFAULT 'free';
-- Índice para lookups por subscription_id (webhook usa muito)
CREATE INDEX idx_profiles_subscription_id
ON profiles (subscription_id)
WHERE subscription_id IS NOT NULL;
-- Índice para lookups por stripe_customer_id
CREATE INDEX idx_profiles_stripe_customer_id
ON profiles (stripe_customer_id)
WHERE stripe_customer_id IS NOT NULL;
-- RLS: usuário só lê/atualiza próprio perfil
CREATE POLICY "Perfil próprio" ON profiles
FOR ALL USING (auth.uid() = id);O índice em subscription_id é importante porque o webhook vai buscar o perfil pelo sub.id em todos os eventos de customer.subscription.*. Sem índice, vira seq scan numa tabela que cresce com seus usuários. Essa atenção a índices vale também para qualquer coluna de lookup frequente — o mesmo princípio discutido em performance com índices no PostgreSQL.
Perguntas frequentes
Posso usar o Stripe em BRL para cobrar assinatura no Brasil?+
Sim. O Stripe processa BRL para contas brasileiras via Stripe Pagamentos. Configure a moeda como `brl` na criação do Price e o valor em centavos. Cartões internacionais são convertidos automaticamente pela rede do cartão. PIX ainda não é suportado nativamente para assinaturas recorrentes — apenas para pagamentos avulsos.
O que acontece com a assinatura se o pagamento falhar?+
O Stripe inicia o fluxo de Smart Retries automaticamente — tenta novamente em intervalos configuráveis por até 4 semanas por padrão. Durante as tentativas, o `subscription_status` muda para `past_due`. Você recebe o evento `invoice.payment_failed` no webhook e pode enviar email alertando o usuário. Após esgotar retries, o status vai para `canceled` e o evento `customer.subscription.deleted` é disparado.
Como testar o fluxo de pagamento localmente sem cobrar cartão real?+
Use o modo de teste do Stripe (chave que começa com `sk_test_`) e cartões de teste documentados em stripe.com/docs/testing. O número `4242 4242 4242 4242` com qualquer CVV e data futura sempre aprova. Para simular falha, use `4000 0000 0000 9995`. Com o Stripe CLI rodando (`stripe listen`), você recebe os webhooks reais do ambiente de teste no seu localhost.
Vale a pena usar o Stripe Customer Portal em vez de construir tela própria?+
Para a maioria dos micro-SaaS, sim. O portal cobre upgrade, downgrade, cancelamento e atualização de cartão com interface pronta, hospedada pelo Stripe, sem manutenção sua. Você perde customização de UI, mas ganha velocidade e conformidade com regras de billing. Construir tela própria só faz sentido quando o fluxo de assinatura é central na experiência do produto (ex.: SaaS com upsell in-app muito específico).
Como lidar com proratação no upgrade de plano?+
O Stripe faz proratação automaticamente quando você troca o price de uma assinatura existente via `stripe.subscriptions.update`. O valor já pago no plano atual é creditado proporcionalmente ao novo plano. Você controla o comportamento com o parâmetro `proration_behavior`: `create_proration` (padrão), `always_invoice` para cobrar imediatamente, ou `none` para ignorar proratação.
Preciso de nota fiscal para cada cobrança feita pelo Stripe?+
Sim, se você está no Brasil como PJ. O Stripe gera um recibo próprio, mas não emite NF-e ou NFS-e brasileira. Você precisa emitir a nota fiscal manualmente ou via integração com sistema de emissão (Nuvem Fiscal, Omie, entre outros) disparada pelo webhook `invoice.payment_succeeded`. O processo depende do seu município e tipo de serviço — consulte seu contador para adequação ao regime tributário.
Calculadora de valor-hora para freelancer
Antes de precificar seu SaaS, saiba quanto cada hora do seu tempo vale. Insira meta mensal, custos fixos e regime (MEI, Simples, autônomo) e veja o valor mínimo por hora para não trabalhar no prejuízo.
Calcular meu valor-hora →Artigos relacionados
Como precificar seu SaaS MVP desde o primeiro cliente
Quanto cobrar no SaaS quando faturamento é zero e você não tem benchmark? Estratégia de preço pra solo founder que quer MRR real, não usuários grátis.
Segurança indie: 5 camadas que seu SaaS precisa (sem overkill)
Guia prático de segurança pra solopreneur. Quais defesas você realmente precisa, quanto custam e onde não vale investir tempo agora.
Como cobrar pelo uso de IA no Stripe: guia de billing para SaaS indie
Aprenda a precificar e cobrar automaticamente pelo consumo de tokens IA no Stripe. Setup prático para solopreneur monetizar produto com Claude, GPT ou Gemini.
UUID v7 no PostgreSQL: quando migrar e ganhar 2x de performance
Guia prático: por que UUID v7 é melhor que v4, impacto real em índices B-tree, como migrar sem downtime e quando vale a pena.