Artigo Build·Desenvolvimento·12 min de leitura

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.

Vitor Morais

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 plano
  • customer.subscription.updated — mudança de plano (upgrade/downgrade)
  • customer.subscription.deleted — cancelamento, volta ao Free
  • invoice.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.

Checkout Session vs. Customer Portal: quando usar cada abordagem
CenárioCheckout SessionCustomer 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
#stripe-billing-nextjs#tabela-precos-saas#micro-saas-nextjs#stripe-webhooks#next-js-billing#saas-indie#supabase-stripe#precificacao-saas

Artigos relacionados