//
Busque em todas as páginas da documentação
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Busque produtos e preços do Stripe no momento da compilação, exiba níveis de preços com comparações de recursos e permita que os usuários alternem entre faturamento mensal e anual. Lide com níveis gratuitos e destaque o plano recomendado.
Busque preços do Stripe:
// lib/pricing.ts
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";
export interface PricingTier {
productId: string;
productName: string;
description: string | null;
monthlyPrice: Stripe.Price | null;
annualPrice: Stripe.Price | null;
features: string[];
highlighted: boolean;
order: number;
}
export async function getPricingTiers(): Promise<PricingTier[]> {
const products = await stripe.products.list({
active: true,
expand: ["data.default_price"],
});
const prices = await stripe.prices.list({
active: true,
type: "recurring",
expand: ["data.product"],
});
const tiers: PricingTier[] = products.data
.filter((p) => p.metadata.show_on_pricing === "true")
.map((product) => {
const productPrices = prices.data.filter(
(p) => (p.product as Stripe.Product).id === product.id
);
return {
productId: product.id,
productName: product.name,
description: product.description,
monthlyPrice:
productPrices.find((p) => p.recurring?.interval === "month") ?? null,
annualPrice:
productPrices.find((p) => p.recurring?.interval === "year") ?? null,
features: JSON.parse(product.metadata.features ?? "[]") as string[],
highlighted: product.metadata.highlighted === "true",
order: parseInt(product.metadata.order ?? "0", 10),
};
})
.sort((a, b) => a.order - b.order);
return tiers;
}// app/pricing/page.tsx
import { getPricingTiers } from "@/lib/pricing";
import { PricingCards } from "./pricing-cards";
export const revalidate = 3600; // Revalida a cada hora
export default async function PricingPage() {
const tiers = await getPricingTiers();
return (
<div className="max-w-6xl mx-auto py-20 px-4">
<div className="text-center mb-16">
<h1 className="text-4xl font-bold mb-4">
Preços simples e transparentes
</h1>
<p className="text-xl text-gray-600">
Comece gratuitamente. Atualize quando estiver pronto.
</p>
</div>
<PricingCards tiers={tiers} />
</div>
);
}// app/pricing/pricing-cards.tsx
"use client";
import { useState } from "react";
import type { PricingTier } from "@/lib/pricing";
import { createSubscriptionCheckout } from "@/app/actions/subscribe";
function formatPrice(amount: number | null, interval: string): string {
if (amount === null) return "Personalizado";
const dollars = amount / 100;
if (interval === "year") {
return `$${Math.round(dollars / 12)}`;
}
return `$${dollars}`;
}
export function PricingCards({ tiers }: { tiers: PricingTier[] }) {
const [annual, setAnnual] = useState(false);
const [loadingTier, setLoadingTier] = useState<string | null>(null);
const freeTier: PricingTier = {
productId: "free",
productName: "Grátis",
description: "Para indivíduos que estão começando",
monthlyPrice: null,
annualPrice: null,
features: ["1 projeto", "100 MB de armazenamento", "Suporte da comunidade"],
highlighted: false,
order: 0,
};
const allTiers = [freeTier, ...tiers];
async function handleSubscribe(tier: PricingTier) {
const price = annual ? tier.annualPrice : tier.monthlyPrice;
if (!price) return;
setLoadingTier(tier.productId);
try {
await createSubscriptionCheckout(price.id);
} catch {
setLoadingTier(null);
}
}
return (
<div>
{/* Alternador de faturamento */}
<div className="flex items-center justify-center gap-4 mb-12">
<span
className={`text-sm font-medium ${
!annual ? "text-gray-900" : "text-gray-500"
}`}
>
Mensal
</span>
<button
onClick={() => setAnnual(!annual)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
annual ? "bg-blue-600" : "bg-gray-300"
}`}
aria-label="Alternar faturamento anual"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
annual ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span
className={`text-sm font-medium ${
annual ? "text-gray-900" : "text-gray-500"
}`}
>
Anual
<span className="ml-1 text-green-600 text-xs font-bold">
Economize 20%
</span>
</span>
</div>
{/* Cartões de preço */}
<div className="grid md:grid-cols-4 gap-6">
{allTiers.map((tier) => {
const price = annual ? tier.annualPrice : tier.monthlyPrice;
const isFree = tier.productId === "free";
const isLoading = loadingTier === tier.productId;
return (
<div
key={tier.productId}
className={`rounded-2xl border p-8 flex flex-col ${
tier.highlighted
? "border-blue-500 ring-2 ring-blue-500 relative scale-105"
: "border-gray-200"
}`}
>
{tier.highlighted && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-xs font-bold px-4 py-1 rounded-full">
Recomendado
</span>
)}
<h3 className="text-lg font-bold">{tier.productName}</h3>
{tier.description && (
<p className="text-gray-500 text-sm mt-1">
{tier.description}
</p>
)}
<div className="mt-6 mb-8">
{isFree ? (
<span className="text-4xl font-bold">$0</span>
) : price ? (
<>
<span className="text-4xl font-bold">
{formatPrice(price.unit_amount, annual ? "year" : "month")}
</span>
<span className="text-gray-500 text-sm">/mês</span>
{annual && (
<p className="text-xs text-gray-500 mt-1">
Faturado anualmente a ${(price.unit_amount! / 100).toFixed(0)}/ano
</p>
)}
</>
) : (
<span className="text-4xl font-bold">Personalizado</span>
)}
</div>
<ul className="space-y-3 mb-8 flex-1">
{tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm">
<span className="text-green-500 mt-0.5">✓</span>
<span>{feature}</span>
</li>
))}
</ul>
{isFree ? (
<a
href="/signup"
className="block text-center bg-gray-100 text-gray-800 py-3 rounded-lg font-medium hover:bg-gray-200 transition-colors"
>
Começar
</a>
) : price ? (
<button
onClick={() => handleSubscribe(tier)}
disabled={isLoading}
className={`w-full py-3 rounded-lg font-medium transition-colors ${
tier.highlighted
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800"
} disabled:opacity-50`}
>
{isLoading ? "Redirecionando..." : "Assinar"}
</button>
) : (
<a
href="/contact"
className="block text-center border border-gray-300 py-3 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
Contate as Vendas
</a>
)}
</div>
);
})}
</div>
</div>
);
}Tabela de comparação de recursos:
// app/pricing/feature-table.tsx
interface FeatureRow {
feature: string;
free: string | boolean;
pro: string | boolean;
business: string | boolean;
enterprise: string | boolean;
}
const features: FeatureRow[] = [
{ feature: "Projetos", free: "1", pro: "10", business: "Ilimitado", enterprise: "Ilimitado" },
{ feature: "Armazenamento", free: "100 MB", pro: "10 GB", business: "100 GB", enterprise: "Ilimitado" },
{ feature: "Acesso à API", free: false, pro: true, business: true, enterprise: true },
{ feature: "Domínio Personalizado", free: false, pro: false, business: true, enterprise: true },
{ feature: "SSO", free: false, pro: false, business: false, enterprise: true },
{ feature: "Suporte Prioritário", free: false, pro: true, business: true, enterprise: true },
];
export function FeatureTable() {
return (
<div className="mt-20">
<h2 className="text-2xl font-bold text-center mb-8">
Compare Recursos
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-4 px-4">Recurso</th>
<th className="text-center py-4 px-4">Grátis</th>
<th className="text-center py-4 px-4">Pro</th>
<th className="text-center py-4 px-4">Business</th>
<th className="text-center py-4 px-4">Enterprise</th>
</tr>
</thead>
<tbody>
{features.map((row) => (
<tr key={row.feature} className="border-b">
<td className="py-4 px-4 font-medium">{row.feature}</td>
{(["free", "pro", "business", "enterprise"] as const).map(
(plan) => (
<td key={plan} className="text-center py-4 px-4">
{typeof row[plan] === "boolean" ? (
row[plan] ? (
<span className="text-green-500">✓</span>
) : (
<span className="text-gray-300">—</span>
)
) : (
row[plan]
)}
</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}features (array JSON), highlighted, order e show_on_pricing. Isso mantém a configuração do produto centralizada no Stripe.revalidate = 3600 usa o ISR do Next.js para armazenar a página em cache por uma hora. Alterações de preço no Stripe aparecem dentro dessa janela sem uma nova implantação.Geração estática com generateStaticParams:
// Busca preços apenas no momento da compilação
export const dynamic = "force-static";
export const revalidate = false;Revalidação sob demanda quando os preços mudam (via webhook):
// app/api/webhooks/stripe/route.ts
import { revalidatePath } from "next/cache";
// Dentro do manipulador de webhook:
case "price.updated":
case "product.updated":
revalidatePath("/pricing");
break;Preços por país:
export async function getPricingTiers(country: string) {
const prices = await stripe.prices.list({
active: true,
currency: country === "GB" ? "gbp" : country === "EU" ? "eur" : "usd",
});
// ... mapear para tiers
}Stripe.Product tem uma propriedade metadata tipada como Record<string, string>. Analise os valores de metadados explicitamente.Stripe.Price tem unit_amount tipado como number | null. É nulo para preços medidos ou em níveis. Sempre verifique nulo antes de exibir.import type Stripe from "stripe";
function formatAmount(price: Stripe.Price): string {
if (price.unit_amount === null) return "Contate-nos";
return `$${(price.unit_amount / 100).toFixed(2)}`;
}revalidate ou cache para evitar chamadas excessivas à API e lentidão no carregamento da página.JSON.parse e números com parseInt.unit_amount do preço anual é o valor anual total, não o equivalente mensal. Divida por 12 para fins de exibição.auto_paging_each ou aumente o limit.stripe.products.list a cada renderização em um componente cliente. Busque dados em um componente de servidor ou Ação de Servidor e passe-os como props.| Abordagem | Prós | Contras |
|---|---|---|
| Buscar da API Stripe | Sempre sincronizado com o Painel | Latência da API, limites de taxa |
| Dados de preços codificados | Carregamento de página mais rápido, sem chamadas de API | Precisa atualizar o código quando os preços mudam |
| Preços gerenciados por CMS | Atualizações não amigáveis para desenvolvedores | Sistema extra para manter |
| Tabela de Preços Stripe (incorporada) | Zero código, mantido pelo Stripe | Personalização muito limitada |
interval: "month" e um com interval: "year".annual alterna qual Preço é exibido e usado para checkout.formatPrice.show_on_pricing controla se um produto aparece na página.features é um array JSON de strings de recursos analisado com JSON.parse.highlighted marca o plano recomendado, e order controla a ordem de classificação de exibição.PricingTier codificado com productId: "free" e preços nulos./signup em vez de um botão de assinatura.allTiers para que sempre apareça primeiro.// Dentro do seu manipulador de webhook Stripe:
case "price.updated":
case "product.updated":
revalidatePath("/pricing");
break;unit_amount é nulo para modelos de preços medidos ou em níveis.if (price.unit_amount === null) return "Contate-nos";auto_paging_each) ou aumentar o parâmetro limit.export interface PricingTier {
productId: string;
productName: string;
description: string | null;
monthlyPrice: Stripe.Price | null;
annualPrice: Stripe.Price | null;
features: string[];
highlighted: boolean;
order: number;
}Stripe.Price | null para lidar com intervalos ausentes.description é string | null porque os produtos Stripe podem não ter descrições.import type Stripe from "stripe";
function formatAmount(price: Stripe.Price): string {
if (price.unit_amount === null) return "Contate-nos";
return `$${(price.unit_amount / 100).toFixed(2)}`;
}unit_amount pois ele é tipado como number | null.FeatureRow usa string | boolean para cada coluna de plano.Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥