Prototype pricing calculator

This commit is contained in:
Prashant Sridharan committed 2026-01-29 14:31:42 +00:00
1 parent 5d85e33bc4
commit 33a809d870
24 files changed
+3101 -1

No files matched your search

@@ -0,0 +1,284 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { ArrowUpRight, ChevronRight, Copy } from 'lucide-react'
import { Button, cn } from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import Panel from '~/components/Panel'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import {
buildProjectionSeries,
calculatePricingReport,
calculateRoiSummary,
decodeInputsFromSearchParams,
estimateAuthComparison,
encodeInputsToSearchParams,
getDefaultInputs,
} from '~/lib/pricing-calculator'
import type { CalculatorInputs, CalculatorStageId } from '~/lib/pricing-calculator'
import CalloutCard from './components/CalloutCard'
import GrowthChart from './components/GrowthChart'
import ReportView from './components/ReportView'
import Stage1ProjectBasics from './stages/Stage1ProjectBasics'
import Stage2UsageEstimation from './stages/Stage2UsageEstimation'
import Stage3Growth from './stages/Stage3Growth'
import Stage4TimeAllocation from './stages/Stage4TimeAllocation'
import SummaryPanel from './summary/SummaryPanel'
export default function PricingCalculator() {
const sendTelemetryEvent = useSendTelemetryEvent()
const [activeStage, setActiveStage] = useState<CalculatorStageId>('stage1')
const [inputs, setInputs] = useState<CalculatorInputs>(() => getDefaultInputs())
// Hydrate state from URL (shareable links)
useEffect(() => {
if (!('URLSearchParams' in window)) return
const params = new URLSearchParams(window.location.search)
const decoded = decodeInputsFromSearchParams(params, getDefaultInputs())
setInputs(decoded.inputs)
}, [])
// Keep URL in sync as inputs change (shallow replace)
useEffect(() => {
if (!('URLSearchParams' in window)) return
const { pathname } = window.location
const params = encodeInputsToSearchParams(inputs)
const search = params.toString()
const newAsPath = pathname + (search ? `?${search}` : '')
history.replaceState({ url: newAsPath }, '', newAsPath)
}, [inputs])
const pricingReport = useMemo(() => calculatePricingReport(inputs), [inputs])
const roiSummary = useMemo(
() =>
calculateRoiSummary({
teamSize: inputs.teamSize,
hourlyCostUsd: inputs.hourlyCostUsd,
timeAllocationOverrides: inputs.timeAllocationOverrides,
}),
[inputs.teamSize, inputs.hourlyCostUsd, inputs.timeAllocationOverrides]
)
const authComparison = useMemo(() => estimateAuthComparison({ mau: inputs.mau }), [inputs.mau])
const projectionSeries = useMemo(() => buildProjectionSeries(inputs), [inputs])
const copyShareableLink = async () => {
const url = window.location.href
try {
await navigator.clipboard.writeText(url)
sendTelemetryEvent({
action: 'www_pricing_calculator_share_link_copied',
properties: { stage: activeStage },
})
} catch {
// Fallback: do nothing (prototype)
}
}
const goToStage = (stage: CalculatorStageId) => {
setActiveStage(stage)
sendTelemetryEvent({
action: 'www_pricing_calculator_stage_changed',
properties: { stage },
})
}
return (
<div className="mx-auto max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
<div className="lg:col-span-8 flex flex-col gap-4">
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-foreground text-2xl">Core calculator</h2>
<p className="text-foreground-lighter text-sm">
Costs update as you adjust inputs. Use the share link to send your estimate to your team.
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="default"
size="tiny"
icon={<Copy className="w-3 h-3" />}
onClick={copyShareableLink}
>
Copy link
</Button>
<Button asChild type="outline" size="tiny" iconRight={<ArrowUpRight className="w-4 h-4" />}>
<Link href="/contact/sales" target="_blank">
Talk to sales
</Link>
</Button>
</div>
</div>
</Panel>
<StagePanel
id="stage1"
title="Stage 1: Project basics"
description="Capture foundational information that shapes pricing and value calculations."
active={activeStage === 'stage1'}
onActivate={() => goToStage('stage1')}
>
<Stage1ProjectBasics inputs={inputs} onChange={setInputs} />
<div className="mt-4 flex justify-end">
<Button type="primary" iconRight={<ChevronRight className="w-4 h-4" />} onClick={() => goToStage('stage2')}>
Continue to usage estimation
</Button>
</div>
</StagePanel>
<StagePanel
id="stage2"
title="Stage 2: Usage estimation"
description="Estimate the six dimensions that drive Supabase costs."
active={activeStage === 'stage2'}
onActivate={() => goToStage('stage2')}
>
<Stage2UsageEstimation
inputs={inputs}
onChange={setInputs}
authComparison={authComparison}
freePlanEgressLimitGb={5}
proEgressIncludedGb={250}
/>
<div className="mt-4 flex justify-end">
<Button type="primary" iconRight={<ChevronRight className="w-4 h-4" />} onClick={() => goToStage('stage3')}>
Continue to growth projections
</Button>
</div>
</StagePanel>
<StagePanel
id="stage3"
title="Stage 3: Growth projections"
description="Model future costs and demonstrate predictability advantage."
active={activeStage === 'stage3'}
onActivate={() => goToStage('stage3')}
>
<Stage3Growth inputs={inputs} onChange={setInputs} />
<div className="mt-6">
<GrowthChart
title="Projected costs over time"
subtitle="Supabase vs common alternatives (estimated)"
series={projectionSeries}
/>
</div>
<div className="mt-4 flex justify-end">
<Button type="primary" iconRight={<ChevronRight className="w-4 h-4" />} onClick={() => goToStage('stage4')}>
Continue to time allocation
</Button>
</div>
</StagePanel>
<StagePanel
id="stage4"
title="Stage 4: Optional time allocation"
description="Adjust time estimates to power the ROI calculation."
active={activeStage === 'stage4'}
onActivate={() => goToStage('stage4')}
>
<Stage4TimeAllocation inputs={inputs} onChange={setInputs} />
<CalloutCard
title="Context"
body="These estimates power your ROI calculation. Skip if you want a quick estimate, or adjust for accuracy."
className="mt-4"
/>
</StagePanel>
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-2" style={{ scrollMarginTop: 120 }}>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-foreground text-2xl">Final report</h2>
<p className="text-foreground-lighter text-sm">
A shareable, detailed breakdown combining pricing and ROI. Estimates are transparent and traceable.
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="default"
size="tiny"
icon={<Copy className="w-3 h-3" />}
onClick={copyShareableLink}
>
Copy link
</Button>
<InfoTooltip side="top" className="max-w-[260px]">
Competitor pricing is sourced from official pricing pages (see report for sources and as-of dates).
</InfoTooltip>
</div>
</div>
<ReportView
pricingReport={pricingReport}
roiSummary={roiSummary}
authComparison={authComparison}
projectionSeries={projectionSeries}
onViewed={() =>
sendTelemetryEvent({
action: 'www_pricing_calculator_report_viewed',
properties: { recommendedPlan: pricingReport.recommended.plan },
})
}
/>
</Panel>
</div>
<div className="lg:col-span-4">
<SummaryPanel
className="lg:sticky lg:top-20"
pricingReport={pricingReport}
roiSummary={roiSummary}
onTalkToSales={() =>
sendTelemetryEvent({
action: 'www_pricing_calculator_talk_to_sales_clicked',
properties: { recommendedPlan: pricingReport.recommended.plan },
})
}
/>
</div>
</div>
</div>
)
}
// comparison label helper removed in multi-compare mode
function StagePanel({
id,
title,
description,
active,
onActivate,
children,
}: {
id: CalculatorStageId
title: string
description: string
active: boolean
onActivate: () => void
children: React.ReactNode
}) {
return (
<Panel outerClassName="w-full" innerClassName={cn('p-5 md:p-6', !active && 'cursor-pointer')}>
<button type="button" className="w-full text-left" onClick={onActivate}>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-foreground text-lg">{title}</h3>
<p className="text-foreground-lighter text-sm">{description}</p>
</div>
<div className={cn('text-foreground-lighter text-xs mt-1', active ? 'opacity-0' : 'opacity-100')}>
Click to edit
</div>
</div>
</button>
{active && <div className="mt-6">{children}</div>}
</Panel>
)
}
@@ -0,0 +1,28 @@
'use client'
import { cn } from 'ui'
import Panel from '~/components/Panel'
export default function CalloutCard({
title,
body,
children,
className,
}: {
title: string
body?: string
children?: React.ReactNode
className?: string
}) {
return (
<Panel
outerClassName={cn('w-full', className)}
innerClassName="p-4 md:p-5 flex flex-col gap-2 bg-alternative"
>
<p className="text-foreground text-sm font-medium">{title}</p>
{body && <p className="text-foreground-lighter text-sm whitespace-pre-line">{body}</p>}
{children}
</Panel>
)
}
@@ -0,0 +1,90 @@
'use client'
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts'
import { cn } from 'ui'
import Panel from '~/components/Panel'
import type { ProjectionSeries } from '~/lib/pricing-calculator'
import { formatUsd } from './format'
type Props = {
title: string
subtitle?: string
series: ProjectionSeries
className?: string
}
export default function GrowthChart({ title, subtitle, series, className }: Props) {
const data = series.points.map((p) => ({
month: p.monthIndex,
supabase: p.supabaseMonthlyUsd,
}))
return (
<Panel outerClassName={cn('w-full', className)} innerClassName="p-5 md:p-6">
<div className="flex flex-col gap-1 mb-4">
<h3 className="text-foreground text-lg">{title}</h3>
{subtitle && <p className="text-foreground-lighter text-sm">{subtitle}</p>}
</div>
<div className="w-full h-[260px] md:h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 10, bottom: 10, left: 0 }}>
<CartesianGrid stroke="hsl(var(--border-default))" strokeDasharray="3 3" />
<XAxis
dataKey="month"
tick={{ fill: 'hsl(var(--foreground-lighter))', fontSize: 12 }}
axisLine={{ stroke: 'hsl(var(--border-default))' }}
tickLine={{ stroke: 'hsl(var(--border-default))' }}
label={{
value: 'Month',
position: 'insideBottom',
offset: -5,
fill: 'hsl(var(--foreground-lighter))',
}}
/>
<YAxis
tick={{ fill: 'hsl(var(--foreground-lighter))', fontSize: 12 }}
axisLine={{ stroke: 'hsl(var(--border-default))' }}
tickLine={{ stroke: 'hsl(var(--border-default))' }}
tickFormatter={(v) => `$${v}`}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--background-surface-100))',
border: '1px solid hsl(var(--border-default))',
}}
labelFormatter={(label) => `Month ${label}`}
formatter={(value: any, name: any) => [formatUsd(Number(value)), formatSeriesName(String(name))]}
/>
<Legend
verticalAlign="bottom"
align="left"
iconSize={8}
wrapperStyle={{ paddingTop: 8, fontSize: 12, lineHeight: '16px' }}
formatter={(value: any) => (
<span className="text-foreground-lighter text-xs">{formatSeriesName(String(value))}</span>
)}
/>
<Line
type="monotone"
dataKey="supabase"
stroke="hsl(var(--brand-default))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Panel>
)
}
function formatSeriesName(key: string) {
switch (key) {
case 'supabase':
return 'Supabase'
default:
return key
}
}
@@ -0,0 +1,31 @@
'use client'
import { Collapsible } from 'ui'
import { ChevronUp } from 'lucide-react'
export default function HowCalculated({
title = 'How we calculated this',
items,
}: {
title?: string
items: string[]
}) {
return (
<Collapsible>
<Collapsible.Trigger asChild>
<button type="button" className="text-sm text-foreground-lighter hover:text-foreground flex items-center gap-2">
<ChevronUp className="data-open-parent:rotate-0 data-closed-parent:rotate-180 h-4 w-4 transition" />
{title}
</button>
</Collapsible.Trigger>
<Collapsible.Content className="mt-2">
<ul className="text-sm text-foreground-lighter list-disc pl-5 space-y-1">
{items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
</Collapsible.Content>
</Collapsible>
)
}
@@ -0,0 +1,344 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
import Panel from '~/components/Panel'
import type { PricingReport, ProjectionSeries, RoiSummary } from '~/lib/pricing-calculator'
import { estimateCompetitorMonthlyUsdForKey } from '~/lib/pricing-calculator/competitors'
import { getCompetitorSnapshot } from '~/lib/pricing-calculator/competitors'
import GrowthChart from './GrowthChart'
import HowCalculated from './HowCalculated'
import { formatNumber, formatUsd } from './format'
type AuthComparison = ReturnType<typeof import('~/lib/pricing-calculator/competitors').estimateAuthComparison>
export default function ReportView({
pricingReport,
roiSummary,
projectionSeries,
authComparison,
onViewed,
}: {
pricingReport: PricingReport
roiSummary: RoiSummary
projectionSeries: ProjectionSeries
authComparison: AuthComparison
onViewed?: () => void
}) {
useEffect(() => {
onViewed?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const recommended = pricingReport.recommended.plan
const estimate = pricingReport.estimates[recommended]
const annualSupabase = estimate.totalMonthlyUsd * 12
// Calculate Convex and AWS costs based on full inputs
const convexEstimate = estimateCompetitorMonthlyUsdForKey('convex', pricingReport.inputs)
const awsEstimate = estimateCompetitorMonthlyUsdForKey('aws', pricingReport.inputs)
const competitorSnapshot = getCompetitorSnapshot()
return (
<div className="flex flex-col gap-4 mt-4">
{/* Executive Summary */}
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
<h3 className="text-foreground text-lg">Executive summary</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="border rounded-lg p-4">
<p className="text-foreground-lighter text-sm">Recommended tier</p>
<p className="text-foreground text-xl font-mono">{recommended.toUpperCase()}</p>
<p className="text-foreground-lighter text-sm mt-1">
Estimated monthly cost: <span className="text-foreground">{formatUsd(estimate.totalMonthlyUsd)}</span>
</p>
<p className="text-foreground-lighter text-xs mt-2">
This estimate is based on Supabase pricing and the inputs you provided.
</p>
</div>
<div className="border rounded-lg p-4">
<p className="text-foreground-lighter text-sm">Estimated annual cost</p>
<p className="text-foreground text-xl font-mono">{formatUsd(annualSupabase)}</p>
<p className="text-foreground-lighter text-xs mt-2">
Subscription, compute, and any overages (based on your inputs).
</p>
</div>
</div>
<p className="text-foreground-lighter text-sm">
Notes: <span className="text-foreground">{pricingReport.recommended.reasons[0] ?? ''}</span>
</p>
</Panel>
{/* Projected Costs over Time */}
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
<h3 className="text-foreground text-lg">Projected costs over time</h3>
<GrowthChart title="Projected costs over time" subtitle="Supabase estimate based on your inputs" series={projectionSeries} />
<HowCalculated
items={[
'Each month, we grow MAU and data according to your Stage 3 inputs',
'We recompute Supabase monthly cost based on the updated usage',
]}
/>
</Panel>
{/* Pricing Details */}
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
<h3 className="text-foreground text-lg">Pricing details</h3>
<div className="flex flex-col gap-2">
{estimate.lineItems.map((li) => (
<div key={li.key} className="flex items-start justify-between gap-4 text-sm">
<div className="flex flex-col">
<span className="text-foreground-lighter">{li.key.replaceAll('_', ' ')}</span>
{li.details?.length ? (
<ul className="text-foreground-muted text-xs list-disc pl-5">
{li.details.map((d) => (
<li key={d}>{d}</li>
))}
</ul>
) : null}
</div>
<span className="text-foreground font-mono" translate="no">
{formatUsd(li.monthlyUsd)}
</span>
</div>
))}
<div className="border-t pt-3 flex items-center justify-between">
<span className="text-foreground">Total</span>
<span className="text-foreground font-mono" translate="no">
{formatUsd(estimate.totalMonthlyUsd)}/mo
</span>
</div>
</div>
<HowCalculated
items={[
'Compute cost = projects × selected compute tier monthly price',
'Compute credits apply once per paid organization and offset compute cost',
'Database included quota is per project; most other quotas are per organization',
'Overages are charged only for usage beyond included quotas (where applicable)',
]}
/>
</Panel>
{/* Value Analysis */}
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
<h3 className="text-foreground text-lg">Value analysis</h3>
<div className="border rounded-lg p-4 flex flex-col gap-4">
<div>
<p className="text-foreground-lighter text-sm">Time reallocation estimate</p>
<p className="text-foreground text-xl font-mono">{formatNumber(roiSummary.hoursRecoveredPerMonth)} hrs/mo</p>
<p className="text-foreground-lighter text-sm mt-1">
That&apos;s {formatNumber(roiSummary.hoursRecoveredPerYear)} hours/year, valued at{' '}
<span className="text-foreground">{formatUsd(roiSummary.valueRecoveredPerYearUsd)}</span> using your hourly rate.
</p>
</div>
<div className="border-t pt-4 flex flex-col gap-3">
<p className="text-foreground text-sm font-medium">How we calculate this</p>
<div className="text-foreground-lighter text-sm space-y-2">
<p>
<strong className="text-foreground">Step 1:</strong> We start with how many hours your team spends each month on infrastructure tasks. If you provided this in Stage 4, we use your numbers. Otherwise, we estimate based on your team size:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Authentication: {formatNumber(roiSummary.breakdown.auth.hoursBefore)} hours/month</li>
<li>Database management: {formatNumber(roiSummary.breakdown.database.hoursBefore)} hours/month</li>
<li>API development: {formatNumber(roiSummary.breakdown.api.hoursBefore)} hours/month</li>
<li>DevOps and infrastructure: {formatNumber(roiSummary.breakdown.devops.hoursBefore)} hours/month</li>
</ul>
<p>
<strong className="text-foreground">Step 2:</strong> We estimate how much time Supabase saves you in each area. Based on typical customer experiences, we apply these reduction percentages:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Authentication: {Math.round(roiSummary.breakdown.auth.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.auth.hoursRecovered)} hours/month recovered</li>
<li>Database management: {Math.round(roiSummary.breakdown.database.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.database.hoursRecovered)} hours/month recovered</li>
<li>API development: {Math.round(roiSummary.breakdown.api.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.api.hoursRecovered)} hours/month recovered</li>
<li>DevOps and infrastructure: {Math.round(roiSummary.breakdown.devops.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.devops.hoursRecovered)} hours/month recovered</li>
</ul>
<p>
<strong className="text-foreground">Step 3:</strong> We add up all the recovered hours ({formatNumber(roiSummary.hoursRecoveredPerMonth)} hours/month) and multiply by your hourly cost rate to get the dollar value: {formatUsd(roiSummary.valueRecoveredPerMonthUsd)}/month, or {formatUsd(roiSummary.valueRecoveredPerYearUsd)} per year.
</p>
</div>
</div>
</div>
</Panel>
{/* Competitive Comparison */}
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<h3 className="text-foreground text-lg">Competitive comparison</h3>
<p className="text-foreground-lighter text-xs">
Pricing as of {new Date(authComparison.snapshot.as_of).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
</p>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-4 bg-surface-200 text-xs">
<div className="p-2 text-foreground-lighter">Provider</div>
<div className="p-2 text-foreground-lighter text-right">Monthly</div>
<div className="p-2 text-foreground-lighter text-right">Annual</div>
<div className="p-2 text-foreground-lighter text-right">Notes</div>
</div>
<Row provider="Supabase" monthly={estimate.totalMonthlyUsd} annual={annualSupabase} notes={`Recommended: ${recommended.toUpperCase()}`} />
{authComparison.firebaseMonthlyUsd != null ? (
<RowWithSources
provider="Firebase"
monthly={authComparison.firebaseMonthlyUsd}
annual={authComparison.firebaseMonthlyUsd * 12}
notes={authComparison.snapshot.providers.firebase.plan_name}
sourceUrls={authComparison.snapshot.providers.firebase.source_urls}
/>
) : (
<RowLink provider="Firebase" href="https://firebase.google.com/pricing" />
)}
{authComparison.auth0MonthlyUsd != null ? (
<RowWithSources
provider="Auth0"
monthly={authComparison.auth0MonthlyUsd}
annual={authComparison.auth0MonthlyUsd * 12}
notes={authComparison.snapshot.providers.auth0.plan_name}
sourceUrls={authComparison.snapshot.providers.auth0.source_urls}
/>
) : (
<RowLink provider="Auth0" href="https://auth0.com/pricing" />
)}
{authComparison.clerkMonthlyUsd != null ? (
<RowWithSources
provider="Clerk"
monthly={authComparison.clerkMonthlyUsd}
annual={authComparison.clerkMonthlyUsd * 12}
notes={authComparison.snapshot.providers.clerk.plan_name}
sourceUrls={authComparison.snapshot.providers.clerk.source_urls}
/>
) : (
<RowLink provider="Clerk" href="https://clerk.com/pricing" />
)}
<RowWithSources
provider="Convex"
monthly={convexEstimate.monthlyUsd}
annual={convexEstimate.monthlyUsd * 12}
notes={competitorSnapshot.providers.convex.plan_name}
sourceUrls={competitorSnapshot.providers.convex.source_urls}
/>
<RowWithSources
provider="AWS"
monthly={awsEstimate.monthlyUsd}
annual={awsEstimate.monthlyUsd * 12}
notes={competitorSnapshot.providers.aws.plan_name}
sourceUrls={competitorSnapshot.providers.aws.source_urls}
/>
</div>
<div className="flex flex-col gap-2 text-foreground-lighter text-xs">
<p>
<strong>Note:</strong> Pricing estimates vary by provider model. Auth providers (Firebase, Auth0, Clerk) are based on authentication costs. Full-stack providers (Convex, AWS) are modeled based on typical usage patterns for database, storage, bandwidth, and compute. See source links and detailed assumptions below for full pricing details.
</p>
<details className="cursor-pointer">
<summary className="text-brand hover:text-brand-600">View detailed assumptions</summary>
<ul className="list-disc pl-5 mt-2 space-y-1">
{authComparison.snapshot.providers.firebase.notes && (
<li>
<strong>Firebase:</strong> {authComparison.snapshot.providers.firebase.notes}
</li>
)}
{authComparison.snapshot.providers.clerk.notes && (
<li>
<strong>Clerk:</strong> {authComparison.snapshot.providers.clerk.notes}
</li>
)}
{authComparison.snapshot.providers.auth0.notes && (
<li>
<strong>Auth0:</strong> {authComparison.snapshot.providers.auth0.notes}
</li>
)}
{competitorSnapshot.providers.convex.notes && (
<li>
<strong>Convex:</strong> {competitorSnapshot.providers.convex.notes}
</li>
)}
{competitorSnapshot.providers.aws.notes && (
<li>
<strong>AWS:</strong> {competitorSnapshot.providers.aws.notes}
</li>
)}
</ul>
</details>
</div>
</Panel>
</div>
)
}
function Row({
provider,
monthly,
annual,
notes,
}: {
provider: string
monthly: number
annual: number
notes: string
}) {
return (
<div className="grid grid-cols-4 border-t text-sm">
<div className="p-2 text-foreground-lighter capitalize">{provider.replaceAll('_', ' ')}</div>
<div className="p-2 text-right text-foreground font-mono" translate="no">
{formatUsd(monthly)}
</div>
<div className="p-2 text-right text-foreground font-mono" translate="no">
{formatUsd(annual)}
</div>
<div className="p-2 text-right text-foreground-lighter">{notes}</div>
</div>
)
}
function RowWithSources({
provider,
monthly,
annual,
notes,
sourceUrls,
}: {
provider: string
monthly: number
annual: number
notes: string
sourceUrls: string[]
}) {
return (
<div className="grid grid-cols-4 border-t text-sm">
<div className="p-2 text-foreground-lighter">{provider}</div>
<div className="p-2 text-right text-foreground font-mono" translate="no">
{formatUsd(monthly)}
</div>
<div className="p-2 text-right text-foreground font-mono" translate="no">
{formatUsd(annual)}
</div>
<div className="p-2 text-right text-foreground-lighter text-xs">
<div className="flex flex-col gap-1 items-end">
<span className="line-clamp-1">{notes}</span>
<div className="flex gap-1">
{sourceUrls.map((url, idx) => (
<Link key={idx} href={url} target="_blank" className="underline text-brand hover:text-brand-600 text-xs">
Source{sourceUrls.length > 1 ? ` ${idx + 1}` : ''}
</Link>
))}
</div>
</div>
</div>
</div>
)
}
function RowLink({ provider, href }: { provider: string; href: string }) {
return (
<div className="grid grid-cols-4 border-t text-sm">
<div className="p-2 text-foreground-lighter">{provider}</div>
<div className="p-2 text-right text-foreground-lighter">—</div>
<div className="p-2 text-right text-foreground-lighter">—</div>
<div className="p-2 text-right">
<Link href={href} target="_blank" className="underline text-brand hover:text-brand-600">
Pricing
</Link>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
'use client'
export function formatUsd(value: number): string {
const rounded = Math.round((value + Number.EPSILON) * 100) / 100
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: rounded % 1 === 0 ? 0 : 2,
}).format(rounded)
}
export function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value)
}
@@ -0,0 +1,120 @@
'use client'
import { Select, Input, cn } from 'ui'
import Panel from '~/components/Panel'
import CalloutCard from '../components/CalloutCard'
import type { CalculatorInputs, CurrentInfrastructure } from '~/lib/pricing-calculator'
const INFRA_OPTIONS: { value: CurrentInfrastructure; label: string }[] = [
{ value: 'starting_fresh', label: 'Starting fresh' },
{ value: 'firebase', label: 'Firebase' },
{ value: 'aws_self_hosted', label: 'AWS/self-hosted' },
{ value: 'auth0_plus_db', label: 'Auth0 + separate database' },
{ value: 'other', label: 'Other' },
]
export default function Stage1ProjectBasics({
inputs,
onChange,
}: {
inputs: CalculatorInputs
onChange: (next: CalculatorInputs) => void
}) {
const set = (patch: Partial<CalculatorInputs>) => onChange({ ...inputs, ...patch })
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<label className="text-foreground text-sm">Number of projects</label>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.projects)}
onChange={(e: any) => set({ projects: Math.max(1, Number(e.target.value || 1)) })}
/>
<p className="text-foreground-lighter text-xs">Most users need 1–3 (dev/staging/prod).</p>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<label className="text-foreground text-sm">Current infrastructure</label>
<Select
id="current-infra"
name="Current infrastructure"
layout="vertical"
value={inputs.currentInfrastructure}
onChange={(e: any) => set({ currentInfrastructure: e.target.value })}
>
{INFRA_OPTIONS.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
<p className="text-foreground-lighter text-xs">Are you starting with a fresh project or migrating?</p>
</Panel>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<label className="text-foreground text-sm">Team size</label>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.teamSize)}
onChange={(e: any) => set({ teamSize: Math.max(1, Number(e.target.value || 1)) })}
/>
<p className="text-foreground-lighter text-xs">Developers working on backend/infrastructure.</p>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<label className="text-foreground text-sm">Average hourly developer cost</label>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.hourlyCostUsd)}
onChange={(e: any) => set({ hourlyCostUsd: Math.max(0, Number(e.target.value || 0)) })}
/>
<p className="text-foreground-lighter text-xs">Default $150/hour (US). Used for time savings.</p>
</Panel>
</div>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<label className="text-foreground text-sm">Compliance needs</label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={inputs.needCompliance}
onChange={(e) => set({ needCompliance: e.target.checked })}
className={cn('h-4 w-4')}
/>
<span className="text-foreground-lighter text-sm">SOC2 / HIPAA / compliance requirements indicated</span>
</div>
</Panel>
{/* Integrated ROI messaging */}
{inputs.currentInfrastructure === 'firebase' && (
<CalloutCard
title="Key consideration"
body="Firebase users typically see 60-80% cost reduction at scale due to Supabase's predictable pricing vs per-operation charges."
/>
)}
{inputs.currentInfrastructure === 'aws_self_hosted' && (
<CalloutCard
title="Key consideration"
body="Teams managing their own Postgres typically spend 40-100 hours/month on DevOps. Supabase eliminates most of this."
/>
)}
{inputs.projects >= 3 && (
<CalloutCard
title="Key consideration"
body="Multi-project setups benefit from Supabase's unified dashboard and consolidated billing. No more juggling separate services."
/>
)}
</div>
)
}
@@ -0,0 +1,410 @@
'use client'
import Link from 'next/link'
import { Slider_Shadcn_, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, cn, Input } from 'ui'
import { ChevronDown } from 'lucide-react'
import { ComputeBadge } from 'ui-patterns/ComputeBadge'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { components } from 'api-types'
import pricingAddOn from '~/data/PricingAddOnTable.json'
import Panel from '~/components/Panel'
import CalloutCard from '../components/CalloutCard'
import type { CalculatorInputs, ComputeTier } from '~/lib/pricing-calculator'
import { formatUsd, formatNumber } from '../components/format'
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
type InfraComputeSize = components['schemas']['ProjectDetailResponse']['infra_compute_size']
type ComputeBadgeSize = InfraComputeSize | '>16XL' | undefined
function toComputeBadgeSize(tier: ComputeTier): ComputeBadgeSize {
switch (tier) {
case 'Micro':
return 'micro'
case 'Small':
return 'small'
case 'Medium':
return 'medium'
case 'Large':
return 'large'
case 'XL':
return 'xlarge'
case '2XL':
return '2xlarge'
case '4XL':
return '4xlarge'
case '8XL':
return '8xlarge'
case '12XL':
return '12xlarge'
case '16XL':
return '16xlarge'
default:
return undefined
}
}
function getComputeTiers(): { tier: ComputeTier; monthlyUsd: number }[] {
const tiers: { tier: string; monthlyUsd: number }[] = []
for (const row of pricingAddOn.database?.rows ?? []) {
const planCol = row.columns?.find((c: any) => c.key === 'plan')
const priceCol = row.columns?.find((c: any) => c.key === 'pricing')
if (!planCol || !priceCol) continue
if (priceCol.value === 'Contact Us') continue
const price = Number(String(priceCol.value).replace('$', '').replaceAll(',', ''))
tiers.push({ tier: String(planCol.value), monthlyUsd: Number.isFinite(price) ? price : 0 })
}
const allowed = new Set([
'Micro',
'Small',
'Medium',
'Large',
'XL',
'2XL',
'4XL',
'8XL',
'12XL',
'16XL',
])
return tiers
.filter((t) => allowed.has(t.tier))
.map((t) => ({ tier: t.tier as ComputeTier, monthlyUsd: t.monthlyUsd }))
}
const COMPUTE_TIERS = getComputeTiers()
export default function Stage2UsageEstimation({
inputs,
onChange,
authComparison,
freePlanEgressLimitGb,
proEgressIncludedGb,
}: {
inputs: CalculatorInputs
onChange: (next: CalculatorInputs) => void
authComparison: ReturnType<typeof import('~/lib/pricing-calculator/competitors').estimateAuthComparison>
freePlanEgressLimitGb: number
proEgressIncludedGb: number
}) {
const set = (patch: Partial<CalculatorInputs>) => onChange({ ...inputs, ...patch })
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">Database size (GB)</p>
<p className="text-foreground-lighter text-xs">Default 2 GB</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.databaseSizeGb)}
onChange={(e: any) => set({ databaseSizeGb: clamp(Number(e.target.value || 0.5), 0.5, 10_000) })}
className="max-w-[120px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.databaseSizeGb]}
min={0.5}
max={100}
step={0.5}
onValueChange={(v) => set({ databaseSizeGb: v[0] })}
/>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">File storage (GB)</p>
<p className="text-foreground-lighter text-xs">Default 10 GB</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.storageSizeGb)}
onChange={(e: any) => set({ storageSizeGb: clamp(Number(e.target.value || 0), 0, 100_000) })}
className="max-w-[120px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.storageSizeGb]}
min={1}
max={500}
step={1}
onValueChange={(v) => set({ storageSizeGb: v[0] })}
/>
</Panel>
</div>
<CalloutCard
title="Key consideration"
body="Supabase includes unlimited API requests. Firebase charges $0.18 per 100,000 reads/writes, which compounds quickly."
/>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">Expected monthly egress (GB)</p>
<p className="text-foreground-lighter text-xs">Default 50 GB</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.egressGb)}
onChange={(e: any) => set({ egressGb: clamp(Number(e.target.value || 0), 0, 100_000) })}
className="max-w-[120px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.egressGb]}
min={0}
max={1000}
step={1}
onValueChange={(v) => set({ egressGb: v[0] })}
/>
</Panel>
{inputs.egressGb <= freePlanEgressLimitGb ? (
<CalloutCard
title="Key consideration"
body="The free tier's 5 GB egress limit is the most common upgrade trigger. At your estimated usage, you're well within Pro limits."
/>
) : inputs.egressGb > proEgressIncludedGb ? (
<CalloutCard
title="Key consideration"
body="High egress workloads like yours benefit from Supabase's flat $0.09/GB vs Firebase's variable regional pricing."
/>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">Monthly active users</p>
<p className="text-foreground-lighter text-xs">Default 10,000</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.mau)}
onChange={(e: any) => set({ mau: clamp(Math.round(Number(e.target.value || 0)), 0, 50_000_000) })}
className="max-w-[140px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.mau]}
min={1000}
max={500000}
step={1000}
onValueChange={(v) => set({ mau: v[0] })}
/>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<p className="text-foreground text-sm font-medium">Authentication needs</p>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-foreground-lighter">
<input type="checkbox" checked={inputs.needSso} onChange={(e) => set({ needSso: e.target.checked })} />
Need SSO/SAML?
</label>
<label className="flex items-center gap-2 text-sm text-foreground-lighter">
<input
type="checkbox"
checked={inputs.needPhoneMfa}
onChange={(e) => set({ needPhoneMfa: e.target.checked })}
/>
Need phone-based MFA?
</label>
</div>
<p className="text-foreground-lighter text-xs">
SSO here refers to end-user SAML 2.0 (Auth). Phone MFA is priced per project add-on.
</p>
</Panel>
</div>
<CalloutCard title="Key consideration">
<div className="flex flex-col gap-3">
<div className="text-sm text-foreground-lighter">
Building your own authentication is error prone and complex. It’s costly to build and costly to maintain.{' '}
<Link
href="https://supabase.com/blog/supabase-auth-build-vs-buy"
target="_blank"
className="transition underline text-brand-link hover:text-brand-600"
>
Read our blog post
</Link>{' '}
for more information.
</div>
</div>
</CalloutCard>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<p className="text-foreground text-sm font-medium">Realtime</p>
<div className="grid grid-cols-1 gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground-lighter text-xs">Concurrent realtime connections</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.realtimePeakConnections)}
onChange={(e: any) =>
set({ realtimePeakConnections: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000) })
}
className="max-w-[140px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.realtimePeakConnections]}
min={0}
max={5000}
step={10}
onValueChange={(v) => set({ realtimePeakConnections: v[0] })}
/>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground-lighter text-xs">Realtime messages per month</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.realtimeMessages)}
onChange={(e: any) =>
set({ realtimeMessages: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000_000) })
}
className="max-w-[160px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.realtimeMessages]}
min={0}
max={10_000_000}
step={50_000}
onValueChange={(v) => set({ realtimeMessages: v[0] })}
/>
</div>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<p className="text-foreground text-sm font-medium">Edge Functions</p>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground-lighter text-xs">Edge function invocations per month</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.edgeInvocations)}
onChange={(e: any) =>
set({ edgeInvocations: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000_000) })
}
className="max-w-[160px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.edgeInvocations]}
min={0}
max={5_000_000}
step={50_000}
onValueChange={(v) => set({ edgeInvocations: v[0] })}
/>
</Panel>
</div>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-4 flex-col md:flex-row">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">Compute tier</p>
<p className="text-foreground-lighter text-xs">
Default Micro. Each project requires dedicated compute.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="outline" size="small" iconRight={<ChevronDown className="w-4 h-4" />}>
<div className="flex items-center gap-2">
<ComputeBadge infraComputeSize={toComputeBadgeSize(inputs.computeTier)} />
<span className="text-foreground">{inputs.computeTier}</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
{COMPUTE_TIERS.map((t) => (
<DropdownMenuItem
key={t.tier}
onClick={() => set({ computeTier: t.tier })}
className={cn(inputs.computeTier === t.tier && 'text-brand-600')}
>
<div className="flex items-center justify-between w-full gap-8">
<span className="flex items-center gap-2">
<ComputeBadge infraComputeSize={toComputeBadgeSize(t.tier)} />
<span>{t.tier}</span>
</span>
<span className="font-mono text-foreground-lighter" translate="no">
{formatUsd(t.monthlyUsd)}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2 text-foreground-lighter text-sm">
<InfoTooltip side="top" className="max-w-[280px]">
Compute is billed hourly. Temporary upgrades for migrations or traffic spikes cost only for the hours used.
</InfoTooltip>
<span>Compute is billed hourly.</span>
</div>
<Button asChild type="default" size="tiny">
<Link href="https://supabase.com/docs/guides/platform/compute-add-ons" target="_blank">
Learn about Compute add-ons
</Link>
</Button>
</Panel>
{/* Upsell triggers during input stages */}
{(inputs.mau >= 100_000 || inputs.needSso) && (
<CalloutCard
title="Find more cost savings"
body="At your scale, custom enterprise pricing typically saves 20-40%. Want to discuss volume options?"
>
<Button asChild type="default" size="tiny" className="w-fit mt-2">
<Link href="/contact/sales" target="_blank">
Talk to sales
</Link>
</Button>
</CalloutCard>
)}
{inputs.databaseSizeGb >= 50 && (
<CalloutCard
title="Upsell"
body="Databases over 50 GB benefit from custom compute configurations. Let's optimize your architecture."
>
<Button asChild type="default" size="tiny" className="w-fit mt-2">
<Link href="/contact/sales" target="_blank">
Schedule consultation
</Link>
</Button>
</CalloutCard>
)}
</div>
)
}
@@ -0,0 +1,92 @@
'use client'
import { Slider_Shadcn_, Select, Input } from 'ui'
import Panel from '~/components/Panel'
import CalloutCard from '../components/CalloutCard'
import type { CalculatorInputs, ProjectionPeriodMonths } from '~/lib/pricing-calculator'
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
export default function Stage3Growth({
inputs,
onChange,
}: {
inputs: CalculatorInputs
onChange: (next: CalculatorInputs) => void
}) {
const set = (patch: Partial<CalculatorInputs>) => onChange({ ...inputs, ...patch })
return (
<div className="flex flex-col gap-4">
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">Expected monthly user growth rate</p>
<p className="text-foreground-lighter text-xs">Default 10%</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.userGrowthRateMonthlyPct)}
onChange={(e: any) =>
set({ userGrowthRateMonthlyPct: clamp(Number(e.target.value || 0), 0, 50) })
}
className="max-w-[120px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.userGrowthRateMonthlyPct]}
min={0}
max={50}
step={1}
onValueChange={(v) => set({ userGrowthRateMonthlyPct: v[0] })}
/>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm font-medium">Expected monthly data growth (GB)</p>
<p className="text-foreground-lighter text-xs">Default 1 GB/month</p>
</div>
<Input
type="number"
size="small"
layout="vertical"
value={String(inputs.dataGrowthGbPerMonth)}
onChange={(e: any) => set({ dataGrowthGbPerMonth: clamp(Number(e.target.value || 0), 0, 100_000) })}
className="max-w-[140px]"
/>
</div>
<Slider_Shadcn_
value={[inputs.dataGrowthGbPerMonth]}
min={0}
max={50}
step={1}
onValueChange={(v) => set({ dataGrowthGbPerMonth: v[0] })}
/>
</Panel>
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<p className="text-foreground text-sm font-medium">Projection period</p>
<Select
id="projection-months"
name="Projection months"
layout="vertical"
value={String(inputs.projectionMonths)}
onChange={(e: any) => set({ projectionMonths: Number(e.target.value) as ProjectionPeriodMonths })}
>
<Select.Option value="12">12 months</Select.Option>
<Select.Option value="36">36 months</Select.Option>
</Select>
</Panel>
<CalloutCard
title="Key consideration"
body="With consumption-based pricing, costs compound with growth. Supabase's tiered model keeps costs predictable."
/>
</div>
)
}
@@ -0,0 +1,65 @@
'use client'
import { Input } from 'ui'
import Panel from '~/components/Panel'
import type { CalculatorInputs, TimeAllocationKey } from '~/lib/pricing-calculator'
const FIELD_LABELS: Record<TimeAllocationKey, string> = {
auth: 'Hours per month on authentication',
database: 'Hours per month on database management',
api: 'Hours per month on API development',
devops: 'Hours per month on DevOps',
}
export default function Stage4TimeAllocation({
inputs,
onChange,
}: {
inputs: CalculatorInputs
onChange: (next: CalculatorInputs) => void
}) {
const merged = inputs.timeAllocationOverrides ?? {}
const setOverride = (key: TimeAllocationKey, value: number) => {
onChange({
...inputs,
timeAllocationOverrides: {
...merged,
[key]: value,
},
})
}
const clearOverride = (key: TimeAllocationKey) => {
const next = { ...merged }
delete (next as any)[key]
onChange({
...inputs,
timeAllocationOverrides: next,
})
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(Object.keys(FIELD_LABELS) as TimeAllocationKey[]).map((key) => (
<Panel key={key} outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
<label className="text-foreground text-sm">{FIELD_LABELS[key]}</label>
<Input
type="number"
size="small"
layout="vertical"
value={merged[key] == null ? '' : String(merged[key])}
placeholder="Use default"
onChange={(e: any) => {
const raw = e.target.value
if (raw === '') return clearOverride(key)
return setOverride(key, Math.max(0, Number(raw)))
}}
/>
<p className="text-foreground-lighter text-xs">Optional override. Leave blank to use smart defaults.</p>
</Panel>
))}
</div>
)
}
@@ -0,0 +1,5 @@
export { default as Stage1ProjectBasics } from './Stage1ProjectBasics'
export { default as Stage2UsageEstimation } from './Stage2UsageEstimation'
export { default as Stage3Growth } from './Stage3Growth'
export { default as Stage4TimeAllocation } from './Stage4TimeAllocation'
@@ -0,0 +1,125 @@
'use client'
import Link from 'next/link'
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
import { Button, cn } from 'ui'
import Panel from '~/components/Panel'
import type { PricingReport, RoiSummary } from '~/lib/pricing-calculator'
import { formatNumber, formatUsd } from '../components/format'
type Props = {
className?: string
pricingReport: PricingReport
roiSummary: RoiSummary
onTalkToSales?: () => void
}
const DIMENSIONS: { key: keyof PricingReport['estimates']['free']['fits']; label: string }[] = [
{ key: 'projects', label: 'Projects' },
{ key: 'database_size', label: 'Database' },
{ key: 'storage_size', label: 'Storage' },
{ key: 'egress', label: 'Egress' },
{ key: 'mau', label: 'MAU' },
{ key: 'sso', label: 'SSO/SAML' },
{ key: 'phone_mfa', label: 'Phone MFA' },
{ key: 'realtime_peak_connections', label: 'Realtime connections' },
{ key: 'realtime_messages', label: 'Realtime messages' },
{ key: 'edge_invocations', label: 'Edge invocations' },
{ key: 'compliance', label: 'Compliance' },
]
function StatusIcon({ status }: { status: string }) {
if (status === 'ok') return <CheckCircle2 className="w-4 h-4 text-brand" />
if (status === 'overage') return <AlertTriangle className="w-4 h-4 text-amber-500" />
return <XCircle className="w-4 h-4 text-red-500" />
}
export default function SummaryPanel({ className, pricingReport, roiSummary, onTalkToSales }: Props) {
const { estimates, recommended } = pricingReport
const recommendedEstimate = estimates[recommended.plan]
const showSalesCta =
recommendedEstimate.totalMonthlyUsd >= 500 || pricingReport.inputs.needCompliance
return (
<Panel outerClassName={cn('w-full', className)} innerClassName="p-5 md:p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-foreground text-lg">Live cost summary</h3>
<p className="text-foreground-lighter text-sm">
Recommended tier: <span className="text-foreground">{recommended.plan.toUpperCase()}</span>
</p>
{recommended.reasons.length > 0 && (
<ul className="text-foreground-lighter text-sm list-disc pl-5 mt-2">
{recommended.reasons.slice(0, 3).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{(['free', 'pro', 'team'] as const).map((plan) => (
<div key={plan} className={cn('border rounded-lg p-3', plan === recommended.plan && 'border-stronger')}>
<p className="text-foreground text-xs font-medium uppercase truncate">{plan}</p>
<p className="text-foreground mt-1 font-mono text-sm" translate="no">
{formatUsd(estimates[plan].totalMonthlyUsd)}
<span className="text-foreground-lighter text-xs font-sans">/mo</span>
</p>
</div>
))}
</div>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-4 bg-surface-200 text-xs">
<div className="p-2 text-foreground-lighter">Dimension</div>
<div className="p-2 text-foreground-lighter text-center">Free</div>
<div className="p-2 text-foreground-lighter text-center">Pro</div>
<div className="p-2 text-foreground-lighter text-center">Team</div>
</div>
{DIMENSIONS.map((d) => (
<div key={d.key} className="grid grid-cols-4 border-t text-sm">
<div className="p-2 text-foreground-lighter">{d.label}</div>
<div className="p-2 flex justify-center">
<StatusIcon status={estimates.free.fits[d.key]} />
</div>
<div className="p-2 flex justify-center">
<StatusIcon status={estimates.pro.fits[d.key]} />
</div>
<div className="p-2 flex justify-center">
<StatusIcon status={estimates.team.fits[d.key]} />
</div>
</div>
))}
</div>
{showSalesCta && (
<Panel outerClassName="w-full" innerClassName="p-4 bg-alternative flex flex-col gap-3">
<p className="text-foreground text-sm font-medium">Need custom pricing?</p>
<p className="text-foreground-lighter text-sm">
For teams spending $500+/month, annual commitments unlock 15-25% savings.
</p>
<Button asChild type="default" size="tiny" onClick={onTalkToSales}>
<Link href="/contact/sales" target="_blank">
Contact sales
</Link>
</Button>
</Panel>
)}
<div className="flex items-center gap-2">
<Button asChild type="outline" size="tiny">
<Link href="/pricing" target="_blank">
View pricing page
</Link>
</Button>
<Button asChild type="default" size="tiny" onClick={onTalkToSales}>
<Link href="/contact/sales" target="_blank">
Talk to sales
</Link>
</Button>
</div>
</Panel>
)
}
@@ -0,0 +1,77 @@
{
"as_of": "2026-01-21",
"currency": "USD",
"assumptions": [
"All costs are monthly list prices and exclude taxes, discounts, annual-commit pricing, and negotiated enterprise contracts.",
"MAU means monthly active users as defined by each provider; definitions differ (not normalized).",
"All calculations assume core authentication for end users and exclude add-ons unless explicitly stated in notes.",
"Costs for SMS/phone verification and other per-message fees are excluded unless explicitly stated; these vary by region and usage.",
"For Firebase, MAU pricing below is for Firebase Authentication when billed via Google Cloud Identity Platform Tier 1 providers (Email/Social/Anonymous/Phone) on pay-as-you-go, and does not include Firestore (which is priced by reads/writes/storage, not MAU)."
],
"providers": {
"auth0": {
"plan_name": "Essentials / Professional / Enterprise (interactive pricing by MAU; exact price for 10k/50k/100k not derivable from page contents)",
"source_urls": [
"https://auth0.com/pricing"
],
"monthly_cost_by_mau": {
"10000": null,
"50000": null,
"100000": null
},
"notes": "Auth0's official pricing page presents an interactive MAU selector and plan cards, but the page content available without executing client-side pricing logic does not expose a deterministic price table for the requested MAU points (10k/50k/100k). The page also indicates an MAU selection maximum of \"30,000+\" and includes a \"Contact us\" path for Enterprise, implying pricing may depend on tier/contract for higher MAU. Because an exact, reproducible monthly price for those MAU values cannot be derived directly from the official pricing page contents, values are null."
},
"clerk": {
"plan_name": "Pro plan",
"source_urls": [
"https://clerk.com/pricing"
],
"monthly_cost_by_mau": {
"10000": 25,
"50000": 825,
"100000": 1825
},
"notes": "Computed from the Pro plan pricing shown: base price $25/month includes 10,000 MAUs; overage is $0.02 per additional MAU. Calculations: 10,000 MAU = $25. 50,000 MAU = $25 + (40,000 * $0.02) = $825. 100,000 MAU = $25 + (90,000 * $0.02) = $1,825. Excludes paid add-ons (for example, enterprise features, advanced security, support upgrades), monthly active organizations (MAOs) overages ($1 per additional MAO beyond included), and additional dashboard seats ($10/mo per additional seat)."
},
"firebase": {
"plan_name": "Firebase Authentication on Blaze (via Google Cloud Identity Platform) — Tier 1 providers",
"source_urls": [
"https://firebase.google.com/docs/auth",
"https://cloud.google.com/identity-platform/pricing",
"https://firebase.google.com/docs/firestore/enterprise/pricing"
],
"monthly_cost_by_mau": {
"10000": 0,
"50000": 0,
"100000": 275
},
"notes": "What is priced here: Firebase Authentication MAU billing for Tier 1 providers (Email/Social/Anonymous/Phone) using Identity Platform pricing. Official Identity Platform Tier 1 MAU pricing: 0–49,999 MAUs = $0; 50,000–99,999 = $0.0055 per MAU; 100,000–999,999 = $0.0046 per MAU; higher tiers continue. Assumption for 100,000 MAUs: tiered (marginal) pricing above the no-cost tier, so billed MAUs = 100,000 - 50,000 = 50,000 at $0.0055 = $275. Excludes SMS/phone and MFA per-message charges (region-dependent) and excludes Tier 2 providers (SAML/OIDC) which are billed separately ($0.015 per MAU beyond 50). Firestore note: Firestore is not priced per MAU; it is billed by reads/writes/deletes/storage and varies heavily with workload. Firestore Standard/Enterprise unit rates and free tier are documented at the Firestore enterprise pricing page linked in source_urls."
},
"convex": {
"plan_name": "Professional plan (usage-based pricing)",
"source_urls": [
"https://www.convex.dev/pricing"
],
"monthly_cost_by_mau": {
"10000": null,
"50000": null,
"100000": null
},
"notes": "Convex pricing is usage-based (function calls, storage, bandwidth, compute) rather than MAU-based. Authentication (Convex Auth and AuthKit via WorkOS) is included at no additional cost in all plans. Estimates are modeled based on typical usage patterns: ~50 function calls per MAU per month (including auth-related function calls), storage costs, and bandwidth. Professional plan base: $25/month includes 1M function calls, 0.5 GiB storage, 10 GiB bandwidth. Overage: $0.00001 per function call, $0.20 per GiB storage, $0.12 per GiB bandwidth. Estimates assume typical backend workload patterns and may vary significantly based on actual usage."
},
"aws": {
"plan_name": "AWS full stack (RDS PostgreSQL, S3, CloudFront, EC2, etc.)",
"source_urls": [
"https://aws.amazon.com/rds/pricing/",
"https://aws.amazon.com/s3/pricing/",
"https://aws.amazon.com/cloudfront/pricing/"
],
"monthly_cost_by_mau": {
"10000": null,
"50000": null,
"100000": null
},
"notes": "AWS pricing is usage-based and varies by service, region, and instance types. Estimates model a typical full-stack setup: RDS PostgreSQL (db.t3.micro to db.t3.medium based on database size), S3 storage, CloudFront egress, EC2 compute for API servers, and other supporting services. Estimates assume US East (N. Virginia) region pricing and typical usage patterns. Actual costs can vary significantly based on instance sizing, data transfer patterns, and service selection. Includes estimated costs for: RDS database ($15-150+/mo depending on size), S3 storage ($0.023/GB), CloudFront egress ($0.085/GB first 10TB), EC2 compute ($15-75+/mo), and other supporting services."
}
}
}
@@ -0,0 +1,275 @@
import competitorSnapshot from '~/data/pricing-calculator-competitors.json'
import type { CalculatorInputs, CompetitorKey, ComparisonPlatform } from './types'
const roundUsd = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100
export type CompetitorSnapshot = typeof competitorSnapshot
export function getCompetitorSnapshot(): CompetitorSnapshot {
return competitorSnapshot
}
function interpolateFromAnchors(mau: number, anchors: Record<string, number | null>): number | null {
const sorted = Object.keys(anchors)
.map(Number)
.filter((k) => anchors[String(k)] != null)
.sort((a, b) => a - b)
if (sorted.length === 0) return null
if (mau <= sorted[0]) return anchors[String(sorted[0])] as number
if (mau >= sorted[sorted.length - 1]) {
const lastKey = String(sorted[sorted.length - 1])
const lastVal = anchors[lastKey] as number
// Extrapolate: assume linear continuation from last anchor
if (sorted.length >= 2) {
const prevKey = String(sorted[sorted.length - 2])
const prevVal = anchors[prevKey] as number
const slope = (lastVal - prevVal) / (sorted[sorted.length - 1] - sorted[sorted.length - 2])
return roundUsd(lastVal + (mau - sorted[sorted.length - 1]) * slope)
}
return lastVal
}
// Interpolate between anchors
for (let i = 0; i < sorted.length - 1; i++) {
if (mau >= sorted[i] && mau <= sorted[i + 1]) {
const lowerKey = String(sorted[i])
const upperKey = String(sorted[i + 1])
const lowerVal = anchors[lowerKey] as number
const upperVal = anchors[upperKey] as number
const t = (mau - sorted[i]) / (sorted[i + 1] - sorted[i])
return roundUsd(lowerVal + t * (upperVal - lowerVal))
}
}
return null
}
export function estimateAuth0MonthlyUsd(mau: number): number | null {
const snapshot = competitorSnapshot.providers.auth0
return interpolateFromAnchors(mau, snapshot.monthly_cost_by_mau)
}
export function estimateClerkMonthlyUsd(mau: number): number | null {
const snapshot = competitorSnapshot.providers.clerk
return interpolateFromAnchors(mau, snapshot.monthly_cost_by_mau)
}
export function estimateFirebaseAuthMonthlyUsd(mau: number): number | null {
const snapshot = competitorSnapshot.providers.firebase
return interpolateFromAnchors(mau, snapshot.monthly_cost_by_mau)
}
export function estimateFirebaseMonthlyUsd(inputs: Pick<CalculatorInputs, 'mau' | 'egressGb'>): number {
// Use snapshot for Firebase Auth (Tier 1 providers)
const authCost = estimateFirebaseAuthMonthlyUsd(inputs.mau) ?? 0
// Note: Firestore is not priced per MAU; it's billed by reads/writes/storage.
// For a full comparison, we'd need Firestore usage estimates, but for this prototype
// we only include the auth component from the snapshot.
return roundUsd(authCost)
}
export function estimateSelfHostedMonthlyUsd(
inputs: Pick<CalculatorInputs, 'databaseSizeGb' | 'storageSizeGb' | 'egressGb' | 'projects'>
): number {
// Prototype baseline for infra: per-project overhead + storage + bandwidth.
const basePerProject = 75
const dbPerGb = 0.25
const storagePerGb = 0.02
const egressPerGb = 0.05
const dbTotalGb = inputs.databaseSizeGb * inputs.projects
return roundUsd(
inputs.projects * basePerProject +
dbTotalGb * dbPerGb +
inputs.storageSizeGb * storagePerGb +
inputs.egressGb * egressPerGb
)
}
export function estimateConvexMonthlyUsd(
inputs: Pick<CalculatorInputs, 'mau' | 'databaseSizeGb' | 'storageSizeGb' | 'egressGb'>
): number {
// Convex Professional plan: $25/month base includes 1M function calls, 0.5 GiB storage, 10 GiB bandwidth
const BASE_MONTHLY = 25
const INCLUDED_FUNCTION_CALLS = 1_000_000
const INCLUDED_STORAGE_GB = 0.5
const INCLUDED_BANDWIDTH_GB = 10
// Estimate function calls: ~50 calls per MAU per month (typical backend workload)
const functionCalls = inputs.mau * 50
const functionCallsOverage = Math.max(0, functionCalls - INCLUDED_FUNCTION_CALLS)
const functionCallsCost = functionCallsOverage * 0.00001 // $0.00001 per call
// Storage: database + file storage
const totalStorageGb = inputs.databaseSizeGb + inputs.storageSizeGb
const storageOverage = Math.max(0, totalStorageGb - INCLUDED_STORAGE_GB)
const storageCost = storageOverage * 0.20 // $0.20 per GiB
// Bandwidth (egress)
const bandwidthOverage = Math.max(0, inputs.egressGb - INCLUDED_BANDWIDTH_GB)
const bandwidthCost = bandwidthOverage * 0.12 // $0.12 per GiB
return roundUsd(BASE_MONTHLY + functionCallsCost + storageCost + bandwidthCost)
}
export function estimateAwsMonthlyUsd(
inputs: Pick<CalculatorInputs, 'databaseSizeGb' | 'storageSizeGb' | 'egressGb' | 'projects' | 'mau'>
): number {
// AWS full stack estimate: RDS, S3, CloudFront, EC2, etc.
// Assumes US East (N. Virginia) region pricing
// RDS PostgreSQL: db.t3.micro ($15/mo) for small DBs, db.t3.small ($30/mo) for medium, db.t3.medium ($60/mo) for large
// Scale based on database size
let rdsCost = 0
const dbTotalGb = inputs.databaseSizeGb * inputs.projects
if (dbTotalGb <= 20) {
rdsCost = 15 // db.t3.micro
} else if (dbTotalGb <= 100) {
rdsCost = 30 // db.t3.small
} else if (dbTotalGb <= 500) {
rdsCost = 60 // db.t3.medium
} else {
rdsCost = 150 // Larger instance estimate
}
rdsCost = rdsCost * inputs.projects // Per project
// S3 storage: $0.023 per GB
const s3StorageCost = inputs.storageSizeGb * 0.023
// CloudFront egress: $0.085 per GB for first 10TB
const cloudfrontCost = inputs.egressGb * 0.085
// EC2 compute for API servers: t3.micro ($7.50/mo) to t3.small ($15/mo) per instance
// Estimate 1-2 instances based on MAU
const ec2Instances = inputs.mau < 10_000 ? 1 : 2
const ec2Cost = ec2Instances * (inputs.mau < 50_000 ? 7.5 : 15)
// Other services (Load Balancer, CloudWatch, etc.): ~$20-50/month baseline
const otherServicesCost = 30
return roundUsd(rdsCost + s3StorageCost + cloudfrontCost + ec2Cost + otherServicesCost)
}
export function estimateCompetitorKeyForInfrastructure(infra: CalculatorInputs['currentInfrastructure']): CompetitorKey {
switch (infra) {
case 'firebase':
return 'firebase'
case 'aws_self_hosted':
return 'self_hosted'
case 'auth0_plus_db':
return 'auth0'
case 'starting_fresh':
case 'other':
default:
return 'firebase'
}
}
export function estimateCompetitorMonthlyUsdForKey(
key: ComparisonPlatform,
inputs: CalculatorInputs
): { key: CompetitorKey; monthlyUsd: number } {
if (key === 'firebase') {
return { key, monthlyUsd: estimateFirebaseMonthlyUsd({ mau: inputs.mau, egressGb: inputs.egressGb }) }
}
if (key === 'clerk') {
const clerkCost = estimateClerkMonthlyUsd(inputs.mau)
return { key, monthlyUsd: clerkCost ?? 0 }
}
if (key === 'auth0') {
// Auth0 + separate database: model as Auth0 auth cost + a light self-hosted DB baseline
const auth = estimateAuth0MonthlyUsd(inputs.mau) ?? 0
const db = estimateSelfHostedMonthlyUsd({
databaseSizeGb: inputs.databaseSizeGb,
storageSizeGb: inputs.storageSizeGb,
egressGb: inputs.egressGb,
projects: inputs.projects,
})
return { key, monthlyUsd: roundUsd(auth + db) }
}
if (key === 'self_hosted') {
return {
key,
monthlyUsd: estimateSelfHostedMonthlyUsd({
databaseSizeGb: inputs.databaseSizeGb,
storageSizeGb: inputs.storageSizeGb,
egressGb: inputs.egressGb,
projects: inputs.projects,
}),
}
}
if (key === 'convex') {
return {
key,
monthlyUsd: estimateConvexMonthlyUsd({
mau: inputs.mau,
databaseSizeGb: inputs.databaseSizeGb,
storageSizeGb: inputs.storageSizeGb,
egressGb: inputs.egressGb,
}),
}
}
if (key === 'aws') {
return {
key,
monthlyUsd: estimateAwsMonthlyUsd({
databaseSizeGb: inputs.databaseSizeGb,
storageSizeGb: inputs.storageSizeGb,
egressGb: inputs.egressGb,
projects: inputs.projects,
mau: inputs.mau,
}),
}
}
return { key, monthlyUsd: 0 }
}
export function estimateCompetitorMonthlyUsd(inputs: CalculatorInputs): { key: CompetitorKey; monthlyUsd: number } {
const key = estimateCompetitorKeyForInfrastructure(inputs.currentInfrastructure) as ComparisonPlatform
return estimateCompetitorMonthlyUsdForKey(key, inputs)
}
export function estimateAuthComparison(inputs: Pick<CalculatorInputs, 'mau'>) {
const mau = inputs.mau
const snapshot = competitorSnapshot
// Supabase auth-only estimate: free if <= 50K, otherwise Pro base as baseline.
const supabase = mau <= 50_000 ? 0 : 25
return {
mau,
supabaseMonthlyUsd: supabase,
auth0MonthlyUsd: estimateAuth0MonthlyUsd(mau),
clerkMonthlyUsd: estimateClerkMonthlyUsd(mau),
firebaseMonthlyUsd: estimateFirebaseAuthMonthlyUsd(mau) ?? 0,
snapshot: {
as_of: snapshot.as_of,
providers: {
auth0: {
plan_name: snapshot.providers.auth0.plan_name,
source_urls: snapshot.providers.auth0.source_urls,
notes: snapshot.providers.auth0.notes,
},
clerk: {
plan_name: snapshot.providers.clerk.plan_name,
source_urls: snapshot.providers.clerk.source_urls,
notes: snapshot.providers.clerk.notes,
},
firebase: {
plan_name: snapshot.providers.firebase.plan_name,
source_urls: snapshot.providers.firebase.source_urls,
notes: snapshot.providers.firebase.notes,
},
},
},
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { CalculatorInputs, ComparisonPlatform, ProjectionSeries } from './types'
import { calculatePricingReport } from './supabase'
import { estimateCompetitorMonthlyUsdForKey } from './competitors'
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100
function compound(value: number, pct: number, periods: number) {
return value * Math.pow(1 + pct / 100, periods)
}
export function buildProjectionSeries(inputs: CalculatorInputs): ProjectionSeries {
const platforms: ComparisonPlatform[] = ['firebase', 'self_hosted', 'auth0', 'clerk', 'convex', 'aws']
const points = Array.from({ length: inputs.projectionMonths }, (_, idx) => {
const mau = Math.round(compound(inputs.mau, inputs.userGrowthRateMonthlyPct, idx))
// Prototype assumption: data growth is split between DB and Storage.
const dbGrowthShare = 0.6
const storageGrowthShare = 0.4
const dataGrowthTotal = inputs.dataGrowthGbPerMonth * idx
const databaseSizeGb = round2(inputs.databaseSizeGb + dataGrowthTotal * dbGrowthShare)
const storageSizeGb = round2(inputs.storageSizeGb + dataGrowthTotal * storageGrowthShare)
// Prototype assumption: egress scales proportionally with MAU
const egressGb = round2((inputs.egressGb * mau) / Math.max(1, inputs.mau))
const monthInputs: CalculatorInputs = {
...inputs,
mau,
databaseSizeGb,
storageSizeGb,
egressGb,
}
const report = calculatePricingReport(monthInputs)
const supabaseMonthlyUsd = report.estimates[report.recommended.plan].totalMonthlyUsd
const competitorsMonthlyUsd = platforms.reduce(
(acc, key) => {
acc[key] = estimateCompetitorMonthlyUsdForKey(key, monthInputs).monthlyUsd
return acc
},
{} as Record<ComparisonPlatform, number>
)
return {
monthIndex: idx + 1,
mau,
databaseSizeGb,
storageSizeGb,
egressGb,
supabaseMonthlyUsd,
competitorsMonthlyUsd,
}
})
const cumulativeSupabase = round2(points.reduce((acc, p) => acc + p.supabaseMonthlyUsd, 0))
const cumulativeCompetitorsUsd = platforms.reduce(
(acc, key) => {
acc[key] = round2(points.reduce((sum, p) => sum + p.competitorsMonthlyUsd[key], 0))
return acc
},
{} as Record<ComparisonPlatform, number>
)
const savingsUsd = platforms.reduce(
(acc, key) => {
acc[key] = round2(cumulativeCompetitorsUsd[key] - cumulativeSupabase)
return acc
},
{} as Record<ComparisonPlatform, number>
)
return {
months: inputs.projectionMonths,
points,
cumulative: {
supabaseUsd: cumulativeSupabase,
competitorsUsd: cumulativeCompetitorsUsd,
savingsUsd,
},
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from './types'
export * from './supabase'
export * from './competitors'
export * from './roi'
export * from './growth'
export * from './serialize'
+74
View File
@@ -0,0 +1,74 @@
import type { CalculatorInputs, RoiSummary, TimeAllocation } from './types'
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100
const REDUCTION_PCT = {
auth: 0.85,
database: 0.65,
api: 0.75,
devops: 0.85,
} as const
export function getDefaultTimeAllocation(teamSize: number): TimeAllocation {
return {
auth: teamSize * 3,
database: teamSize * 5,
api: teamSize * 8,
devops: teamSize * 4,
}
}
export function calculateRoiSummary(inputs: Pick<CalculatorInputs, 'teamSize' | 'hourlyCostUsd' | 'timeAllocationOverrides'>): RoiSummary {
const defaults = getDefaultTimeAllocation(inputs.teamSize)
const merged: TimeAllocation = {
auth: inputs.timeAllocationOverrides?.auth ?? defaults.auth,
database: inputs.timeAllocationOverrides?.database ?? defaults.database,
api: inputs.timeAllocationOverrides?.api ?? defaults.api,
devops: inputs.timeAllocationOverrides?.devops ?? defaults.devops,
}
const breakdown = {
auth: {
hoursBefore: merged.auth,
hoursRecovered: merged.auth * REDUCTION_PCT.auth,
reductionPct: REDUCTION_PCT.auth,
},
database: {
hoursBefore: merged.database,
hoursRecovered: merged.database * REDUCTION_PCT.database,
reductionPct: REDUCTION_PCT.database,
},
api: {
hoursBefore: merged.api,
hoursRecovered: merged.api * REDUCTION_PCT.api,
reductionPct: REDUCTION_PCT.api,
},
devops: {
hoursBefore: merged.devops,
hoursRecovered: merged.devops * REDUCTION_PCT.devops,
reductionPct: REDUCTION_PCT.devops,
},
} as const
const hoursRecoveredPerMonth =
breakdown.auth.hoursRecovered +
breakdown.database.hoursRecovered +
breakdown.api.hoursRecovered +
breakdown.devops.hoursRecovered
const valueRecoveredPerMonthUsd = hoursRecoveredPerMonth * inputs.hourlyCostUsd
return {
hoursRecoveredPerMonth: round2(hoursRecoveredPerMonth),
hoursRecoveredPerYear: round2(hoursRecoveredPerMonth * 12),
valueRecoveredPerMonthUsd: round2(valueRecoveredPerMonthUsd),
valueRecoveredPerYearUsd: round2(valueRecoveredPerMonthUsd * 12),
breakdown: {
auth: breakdown.auth,
database: breakdown.database,
api: breakdown.api,
devops: breakdown.devops,
},
}
}
@@ -0,0 +1,115 @@
import { pricingCalculatorVersion } from 'shared-data'
import type { CalculatorInputs, ComputeTier, CurrentInfrastructure, ProjectionPeriodMonths } from './types'
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const toBool = (v: string | null) => v === '1' || v === 'true'
const toNum = (v: string | null, fallback: number) => {
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
export function getDefaultInputs(): CalculatorInputs {
return {
projects: 1,
currentInfrastructure: 'starting_fresh',
teamSize: 2,
hourlyCostUsd: 150,
needCompliance: false,
databaseSizeGb: 2,
storageSizeGb: 10,
egressGb: 50,
mau: 10_000,
needSso: false,
needPhoneMfa: false,
realtimePeakConnections: 100,
realtimeMessages: 1_000_000,
edgeInvocations: 500_000,
computeTier: 'Micro',
userGrowthRateMonthlyPct: 10,
dataGrowthGbPerMonth: 1,
projectionMonths: 12,
timeAllocationOverrides: {},
}
}
export function encodeInputsToSearchParams(inputs: CalculatorInputs): URLSearchParams {
const p = new URLSearchParams()
p.set('v', pricingCalculatorVersion)
p.set('projects', String(inputs.projects))
p.set('infra', inputs.currentInfrastructure)
p.set('team', String(inputs.teamSize))
p.set('hourly', String(inputs.hourlyCostUsd))
p.set('compliance', inputs.needCompliance ? '1' : '0')
p.set('db', String(inputs.databaseSizeGb))
p.set('storage', String(inputs.storageSizeGb))
p.set('egress', String(inputs.egressGb))
p.set('mau', String(inputs.mau))
p.set('sso', inputs.needSso ? '1' : '0')
p.set('phone_mfa', inputs.needPhoneMfa ? '1' : '0')
p.set('rtc', String(inputs.realtimePeakConnections))
p.set('rtm', String(inputs.realtimeMessages))
p.set('edge', String(inputs.edgeInvocations))
p.set('compute', inputs.computeTier)
p.set('growth', String(inputs.userGrowthRateMonthlyPct))
p.set('data_growth', String(inputs.dataGrowthGbPerMonth))
p.set('months', String(inputs.projectionMonths))
if (inputs.timeAllocationOverrides?.auth != null) p.set('ta_auth', String(inputs.timeAllocationOverrides.auth))
if (inputs.timeAllocationOverrides?.database != null)
p.set('ta_db', String(inputs.timeAllocationOverrides.database))
if (inputs.timeAllocationOverrides?.api != null) p.set('ta_api', String(inputs.timeAllocationOverrides.api))
if (inputs.timeAllocationOverrides?.devops != null) p.set('ta_devops', String(inputs.timeAllocationOverrides.devops))
return p
}
export function decodeInputsFromSearchParams(params: URLSearchParams, fallback: CalculatorInputs = getDefaultInputs()): {
inputs: CalculatorInputs
versionMatch: boolean
} {
const v = params.get('v')
const versionMatch = !v || v === pricingCalculatorVersion
const inputs: CalculatorInputs = {
...fallback,
projects: clamp(Math.round(toNum(params.get('projects'), fallback.projects)), 1, 50),
currentInfrastructure: (params.get('infra') as CurrentInfrastructure) ?? fallback.currentInfrastructure,
teamSize: clamp(Math.round(toNum(params.get('team'), fallback.teamSize)), 1, 500),
hourlyCostUsd: clamp(toNum(params.get('hourly'), fallback.hourlyCostUsd), 0, 1000),
needCompliance: toBool(params.get('compliance')),
databaseSizeGb: clamp(toNum(params.get('db'), fallback.databaseSizeGb), 0.5, 10_000),
storageSizeGb: clamp(toNum(params.get('storage'), fallback.storageSizeGb), 0, 100_000),
egressGb: clamp(toNum(params.get('egress'), fallback.egressGb), 0, 100_000),
mau: clamp(Math.round(toNum(params.get('mau'), fallback.mau)), 0, 50_000_000),
needSso: toBool(params.get('sso')),
needPhoneMfa: toBool(params.get('phone_mfa')),
realtimePeakConnections: clamp(Math.round(toNum(params.get('rtc'), fallback.realtimePeakConnections)), 0, 10_000_000),
realtimeMessages: clamp(Math.round(toNum(params.get('rtm'), fallback.realtimeMessages)), 0, 10_000_000_000),
edgeInvocations: clamp(Math.round(toNum(params.get('edge'), fallback.edgeInvocations)), 0, 10_000_000_000),
computeTier: (params.get('compute') as ComputeTier) ?? fallback.computeTier,
userGrowthRateMonthlyPct: clamp(toNum(params.get('growth'), fallback.userGrowthRateMonthlyPct), 0, 50),
dataGrowthGbPerMonth: clamp(toNum(params.get('data_growth'), fallback.dataGrowthGbPerMonth), 0, 100_000),
projectionMonths: (Math.round(toNum(params.get('months'), fallback.projectionMonths)) as ProjectionPeriodMonths) ?? fallback.projectionMonths,
// Note: we intentionally ignore any `compare` param in v1 multi-compare mode.
timeAllocationOverrides: {
auth: params.get('ta_auth') ? toNum(params.get('ta_auth'), fallback.timeAllocationOverrides?.auth ?? 0) : undefined,
database: params.get('ta_db') ? toNum(params.get('ta_db'), fallback.timeAllocationOverrides?.database ?? 0) : undefined,
api: params.get('ta_api') ? toNum(params.get('ta_api'), fallback.timeAllocationOverrides?.api ?? 0) : undefined,
devops: params.get('ta_devops') ? toNum(params.get('ta_devops'), fallback.timeAllocationOverrides?.devops ?? 0) : undefined,
},
}
return { inputs, versionMatch }
}
+373
View File
@@ -0,0 +1,373 @@
import { supabasePricingModel } from 'shared-data'
import type { PricingCalculatorPlan } from 'shared-data'
import pricingAddOn from '~/data/PricingAddOnTable.json'
import type { CalculatorInputs, ComputeTier, FitStatus, PlanEstimate, PricingReport, UsageDimension } from './types'
const roundUsd = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100
const parseUsd = (value: string) => {
const normalized = value.replace('$', '').replaceAll(',', '').trim()
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : 0
}
const getComputeTierPriceMap = (): Record<string, number> => {
// `PricingAddOnTable.json` stores compute as rows with columns; we normalize into a map
const rows = pricingAddOn.database?.rows ?? []
const map: Record<string, number> = {}
for (const row of rows) {
const planCol = row.columns?.find((c: any) => c.key === 'plan')
const priceCol = row.columns?.find((c: any) => c.key === 'pricing')
if (!planCol || !priceCol) continue
if (priceCol.value === 'Contact Us') continue
map[String(planCol.value)] = parseUsd(String(priceCol.value))
}
return map
}
const COMPUTE_TIER_PRICE_MAP = getComputeTierPriceMap()
export function getComputeTierMonthlyUsd(tier: ComputeTier): number {
return COMPUTE_TIER_PRICE_MAP[tier] ?? 0
}
function allFitOkOrOverage(fits: Record<UsageDimension, FitStatus>) {
return !Object.values(fits).some((s) => s === 'unavailable' || s === 'limit_exceeded')
}
export function calculatePlanEstimate(
inputs: CalculatorInputs,
plan: PricingCalculatorPlan
): PlanEstimate {
const planModel = supabasePricingModel.plans[plan]
const fits: Record<UsageDimension, FitStatus> = {
projects: 'ok',
compute: 'ok',
database_size: 'ok',
storage_size: 'ok',
egress: 'ok',
mau: 'ok',
sso: 'ok',
phone_mfa: 'ok',
realtime_peak_connections: 'ok',
realtime_messages: 'ok',
edge_invocations: 'ok',
compliance: 'ok',
}
// Projects (Free plan limit)
if (plan === 'free' && planModel.included.activeProjectsLimit) {
fits.projects = inputs.projects <= planModel.included.activeProjectsLimit ? 'ok' : 'limit_exceeded'
}
// Compliance requirement (prototype: maps to Team/Enterprise signals)
if (inputs.needCompliance) {
fits.compliance = plan === 'team' ? 'ok' : 'unavailable'
}
// DB size (per project quota)
const dbIncludedPerProject = planModel.included.databaseSizeGbPerProject
if (plan === 'free') {
fits.database_size = inputs.databaseSizeGb <= dbIncludedPerProject ? 'ok' : 'limit_exceeded'
} else {
fits.database_size = inputs.databaseSizeGb <= dbIncludedPerProject ? 'ok' : 'overage'
}
// Storage (org quota)
if (plan === 'free') {
fits.storage_size = inputs.storageSizeGb <= planModel.included.storageSizeGbPerOrg ? 'ok' : 'limit_exceeded'
} else {
fits.storage_size = inputs.storageSizeGb <= planModel.included.storageSizeGbPerOrg ? 'ok' : 'overage'
}
// Egress (org quota)
if (plan === 'free') {
fits.egress = inputs.egressGb <= planModel.included.egressGbPerOrg ? 'ok' : 'limit_exceeded'
} else {
fits.egress = inputs.egressGb <= planModel.included.egressGbPerOrg ? 'ok' : 'overage'
}
// MAU (org quota)
if (plan === 'free') {
fits.mau = inputs.mau <= planModel.included.mauPerOrg ? 'ok' : 'limit_exceeded'
} else {
fits.mau = inputs.mau <= planModel.included.mauPerOrg ? 'ok' : 'overage'
}
// SSO / SAML (end-user auth SSO)
if (inputs.needSso) {
if (!planModel.availability.sso) {
fits.sso = 'unavailable'
} else {
const ssoMauAssumption = inputs.mau // prototype assumption: all MAUs are SSO users
fits.sso = ssoMauAssumption <= planModel.included.ssoMauPerOrg ? 'ok' : 'overage'
}
}
// Phone MFA add-on
if (inputs.needPhoneMfa) {
fits.phone_mfa = plan === 'free' ? 'unavailable' : 'ok'
}
// Realtime peak connections
if (plan === 'free') {
fits.realtime_peak_connections =
inputs.realtimePeakConnections <= planModel.included.realtimePeakConnectionsPerOrg
? 'ok'
: 'limit_exceeded'
} else {
fits.realtime_peak_connections =
inputs.realtimePeakConnections <= planModel.included.realtimePeakConnectionsPerOrg ? 'ok' : 'overage'
}
// Realtime messages
if (plan === 'free') {
fits.realtime_messages =
inputs.realtimeMessages <= planModel.included.realtimeMessagesPerOrg ? 'ok' : 'limit_exceeded'
} else {
fits.realtime_messages = inputs.realtimeMessages <= planModel.included.realtimeMessagesPerOrg ? 'ok' : 'overage'
}
// Edge invocations
if (plan === 'free') {
fits.edge_invocations =
inputs.edgeInvocations <= planModel.included.edgeInvocationsPerOrg ? 'ok' : 'limit_exceeded'
} else {
fits.edge_invocations = inputs.edgeInvocations <= planModel.included.edgeInvocationsPerOrg ? 'ok' : 'overage'
}
const lineItems: PlanEstimate['lineItems'] = []
// Base subscription
lineItems.push({
key: 'subscription',
monthlyUsd: planModel.subscriptionMonthlyUsd,
})
// Compute (paid plans only)
const computeTierMonthlyUsd = getComputeTierMonthlyUsd(inputs.computeTier)
const computeMonthlyUsd = plan === 'free' ? 0 : roundUsd(inputs.projects * computeTierMonthlyUsd)
if (plan !== 'free') {
lineItems.push({
key: 'compute',
monthlyUsd: computeMonthlyUsd,
details: [
`${inputs.projects} project${inputs.projects === 1 ? '' : 's'} × $${roundUsd(computeTierMonthlyUsd)}/month`,
],
})
}
// Compute credits (paid plans only)
if (plan !== 'free') {
const credit = supabasePricingModel.credits.computeCreditsMonthlyUsd
const applied = Math.min(credit, computeMonthlyUsd)
if (applied > 0) {
lineItems.push({
key: 'compute_credits',
monthlyUsd: roundUsd(-applied),
details: [`-$${roundUsd(applied)} compute credits applied`],
})
}
}
// DB size overage (paid plans only)
if (plan !== 'free') {
const included = planModel.included.databaseSizeGbPerProject
const overGbPerProject = Math.max(0, inputs.databaseSizeGb - included)
const overGbTotal = overGbPerProject * inputs.projects
const cost = roundUsd(overGbTotal * planModel.overage.databaseSizeUsdPerGb)
if (cost > 0) {
lineItems.push({
key: 'database_overage',
monthlyUsd: cost,
details: [
`${roundUsd(overGbPerProject)} GB over per project × ${inputs.projects} projects`,
`$${planModel.overage.databaseSizeUsdPerGb}/GB`,
],
})
}
}
// Storage overage
if (plan !== 'free') {
const overGb = Math.max(0, inputs.storageSizeGb - planModel.included.storageSizeGbPerOrg)
const cost = roundUsd(overGb * planModel.overage.storageSizeUsdPerGb)
if (cost > 0) {
lineItems.push({
key: 'storage_overage',
monthlyUsd: cost,
details: [`${roundUsd(overGb)} GB over`, `$${planModel.overage.storageSizeUsdPerGb}/GB`],
})
}
}
// Egress overage
if (plan !== 'free') {
const overGb = Math.max(0, inputs.egressGb - planModel.included.egressGbPerOrg)
const cost = roundUsd(overGb * planModel.overage.egressUsdPerGb)
if (cost > 0) {
lineItems.push({
key: 'egress_overage',
monthlyUsd: cost,
details: [`${roundUsd(overGb)} GB over`, `$${planModel.overage.egressUsdPerGb}/GB`],
})
}
}
// MAU overage
if (plan !== 'free') {
const over = Math.max(0, inputs.mau - planModel.included.mauPerOrg)
const cost = roundUsd(over * planModel.overage.mauUsdPerMau)
if (cost > 0) {
lineItems.push({
key: 'mau_overage',
monthlyUsd: cost,
details: [`${over.toLocaleString()} MAU over`, `$${planModel.overage.mauUsdPerMau} per MAU`],
})
}
}
// SSO MAU overage (if enabled)
if (plan !== 'free' && inputs.needSso) {
const ssoMauAssumption = inputs.mau
const over = Math.max(0, ssoMauAssumption - planModel.included.ssoMauPerOrg)
const cost = roundUsd(over * planModel.overage.ssoMauUsdPerMau)
if (cost > 0) {
lineItems.push({
key: 'sso_overage',
monthlyUsd: cost,
details: [
`${over.toLocaleString()} SSO MAU over`,
`$${planModel.overage.ssoMauUsdPerMau} per SSO MAU`,
],
})
}
}
// Realtime peak connections overage
if (plan !== 'free') {
const over = Math.max(0, inputs.realtimePeakConnections - planModel.included.realtimePeakConnectionsPerOrg)
const cost = roundUsd((over / 1000) * planModel.overage.realtimePeakConnectionsUsdPer1000)
if (cost > 0) {
lineItems.push({
key: 'realtime_peak_connections_overage',
monthlyUsd: cost,
details: [`${over.toLocaleString()} over`, `$${planModel.overage.realtimePeakConnectionsUsdPer1000} per 1000`],
})
}
}
// Realtime messages overage
if (plan !== 'free') {
const over = Math.max(0, inputs.realtimeMessages - planModel.included.realtimeMessagesPerOrg)
const cost = roundUsd((over / 1_000_000) * planModel.overage.realtimeMessagesUsdPerMillion)
if (cost > 0) {
lineItems.push({
key: 'realtime_messages_overage',
monthlyUsd: cost,
details: [
`${Math.round(over / 1_000_000 * 100) / 100} million over`,
`$${planModel.overage.realtimeMessagesUsdPerMillion} per million`,
],
})
}
}
// Edge invocations overage
if (plan !== 'free') {
const over = Math.max(0, inputs.edgeInvocations - planModel.included.edgeInvocationsPerOrg)
const cost = roundUsd((over / 1_000_000) * planModel.overage.edgeInvocationsUsdPerMillion)
if (cost > 0) {
lineItems.push({
key: 'edge_invocations_overage',
monthlyUsd: cost,
details: [
`${Math.round(over / 1_000_000 * 100) / 100} million over`,
`$${planModel.overage.edgeInvocationsUsdPerMillion} per million`,
],
})
}
}
// Phone MFA add-on (if enabled)
if (plan !== 'free' && inputs.needPhoneMfa) {
const addon = supabasePricingModel.addons.phoneMfa
const cost = roundUsd(
addon.monthlyUsdFirstProject + Math.max(0, inputs.projects - 1) * addon.monthlyUsdPerAdditionalProject
)
if (cost > 0) {
lineItems.push({
key: 'phone_mfa_addon',
monthlyUsd: cost,
details: [
`$${addon.monthlyUsdFirstProject} for first project`,
`$${addon.monthlyUsdPerAdditionalProject} per additional project`,
],
})
}
}
const totalMonthlyUsd = roundUsd(lineItems.reduce((acc, item) => acc + item.monthlyUsd, 0))
return {
plan,
totalMonthlyUsd: allFitOkOrOverage(fits) ? totalMonthlyUsd : totalMonthlyUsd,
fits,
lineItems,
}
}
export function calculatePricingReport(inputs: CalculatorInputs): PricingReport {
const estimates = {
free: calculatePlanEstimate(inputs, 'free'),
pro: calculatePlanEstimate(inputs, 'pro'),
team: calculatePlanEstimate(inputs, 'team'),
} satisfies Record<PricingCalculatorPlan, PlanEstimate>
const candidates: PricingCalculatorPlan[] = (['free', 'pro', 'team'] as const).filter((p) =>
allFitOkOrOverage(estimates[p].fits)
)
const recommendedPlan: PricingCalculatorPlan =
candidates.length === 0
? 'team'
: candidates.reduce((best, next) =>
estimates[next].totalMonthlyUsd < estimates[best].totalMonthlyUsd ? next : best
)
const reasons: string[] = []
if (recommendedPlan === 'free') {
reasons.push('Your usage fits within Free plan limits.')
}
if (recommendedPlan === 'pro') {
if (inputs.needSso) reasons.push('SSO/SAML requires a paid plan.')
if (inputs.needPhoneMfa) reasons.push('Phone-based MFA is available on paid plans.')
if (
estimates.free.fits.egress !== 'ok' ||
estimates.free.fits.mau !== 'ok' ||
estimates.free.fits.storage_size !== 'ok' ||
estimates.free.fits.database_size !== 'ok'
) {
reasons.push('Your estimated usage exceeds Free plan limits.')
}
reasons.push('Pro plan is the lowest tier that supports your inputs.')
}
if (recommendedPlan === 'team') {
if (inputs.needCompliance) reasons.push('Your compliance requirements require Team tier features.')
reasons.push('Team plan is the lowest tier that supports your inputs.')
}
return {
inputs,
estimates,
recommended: {
plan: recommendedPlan,
reasons,
},
}
}
+147
View File
@@ -0,0 +1,147 @@
import type { PricingCalculatorPlan } from 'shared-data'
export type CurrentInfrastructure =
| 'starting_fresh'
| 'firebase'
| 'aws_self_hosted'
| 'auth0_plus_db'
| 'other'
export type ProjectionPeriodMonths = 12 | 36
export type ComputeTier =
| 'Micro'
| 'Small'
| 'Medium'
| 'Large'
| 'XL'
| '2XL'
| '4XL'
| '8XL'
| '12XL'
| '16XL'
export type CalculatorStageId = 'stage1' | 'stage2' | 'stage3' | 'stage4'
export type FitStatus = 'ok' | 'overage' | 'unavailable' | 'limit_exceeded'
export type UsageDimension =
| 'projects'
| 'compute'
| 'database_size'
| 'storage_size'
| 'egress'
| 'mau'
| 'sso'
| 'phone_mfa'
| 'realtime_peak_connections'
| 'realtime_messages'
| 'edge_invocations'
| 'compliance'
export type TimeAllocationKey = 'auth' | 'database' | 'api' | 'devops'
export type TimeAllocation = Record<TimeAllocationKey, number>
export type CalculatorInputs = {
// Stage 1
projects: number
currentInfrastructure: CurrentInfrastructure
teamSize: number
hourlyCostUsd: number
needCompliance: boolean
// Stage 2
databaseSizeGb: number
storageSizeGb: number
egressGb: number
mau: number
needSso: boolean
needPhoneMfa: boolean
realtimePeakConnections: number
realtimeMessages: number
edgeInvocations: number
computeTier: ComputeTier
// Stage 3
userGrowthRateMonthlyPct: number
dataGrowthGbPerMonth: number
projectionMonths: ProjectionPeriodMonths
// Stage 4 (optional overrides)
timeAllocationOverrides?: Partial<TimeAllocation>
}
export type CostLineItemKey =
| 'subscription'
| 'compute'
| 'compute_credits'
| 'database_overage'
| 'storage_overage'
| 'egress_overage'
| 'mau_overage'
| 'sso_overage'
| 'realtime_peak_connections_overage'
| 'realtime_messages_overage'
| 'edge_invocations_overage'
| 'phone_mfa_addon'
export type CostLineItem = {
key: CostLineItemKey
monthlyUsd: number
details?: string[]
}
export type PlanEstimate = {
plan: PricingCalculatorPlan
totalMonthlyUsd: number
fits: Record<UsageDimension, FitStatus>
lineItems: CostLineItem[]
}
export type PricingReport = {
inputs: CalculatorInputs
estimates: Record<PricingCalculatorPlan, PlanEstimate>
recommended: {
plan: PricingCalculatorPlan
reasons: string[]
}
}
export type RoiSummary = {
hoursRecoveredPerMonth: number
hoursRecoveredPerYear: number
valueRecoveredPerMonthUsd: number
valueRecoveredPerYearUsd: number
breakdown: Record<TimeAllocationKey, { hoursBefore: number; hoursRecovered: number; reductionPct: number }>
}
export type CompetitorKey = 'supabase' | 'firebase' | 'auth0' | 'clerk' | 'self_hosted' | 'convex' | 'aws'
export type ComparisonPlatform = Exclude<CompetitorKey, 'supabase'>
export type CostComparison = {
monthlyUsd: Record<CompetitorKey, number>
annualUsd: Record<CompetitorKey, number>
}
export type ProjectionPoint = {
monthIndex: number
mau: number
databaseSizeGb: number
storageSizeGb: number
egressGb: number
supabaseMonthlyUsd: number
competitorsMonthlyUsd: Record<ComparisonPlatform, number>
}
export type ProjectionSeries = {
months: ProjectionPeriodMonths
points: ProjectionPoint[]
cumulative: {
supabaseUsd: number
competitorsUsd: Record<ComparisonPlatform, number>
savingsUsd: Record<ComparisonPlatform, number>
}
}
+97
View File
@@ -0,0 +1,97 @@
import { NextSeo } from 'next-seo'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ArrowDownIcon } from '@heroicons/react/outline'
import { Button } from 'ui'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import Panel from '~/components/Panel'
import Logos from '~/components/logos'
import PricingCalculator from '~/components/PricingCalculator/PricingCalculator'
export default function PricingCalculatorPage() {
const router = useRouter()
const meta_title = 'Pricing Calculator | Supabase'
const meta_description =
"Estimate your Supabase bill in 60 seconds. Compare alternatives and generate a shareable report you can send to your team."
return (
<DefaultLayout>
<NextSeo
title={meta_title}
description={meta_description}
openGraph={{
title: meta_title,
description: meta_description,
url: `https://supabase.com/${router.pathname}`,
images: [
{
url: `https://supabase.com/images/og/supabase-og.png`,
},
],
}}
/>
<SectionContainer className="!pb-8 md:!pb-12 !pt-10 md:!pt-14">
<div className="mx-auto max-w-3xl text-center flex flex-col items-center gap-4">
<h1 className="h1">Know your costs before you build</h1>
<p className="text-foreground-light text-lg">
Estimate your Supabase bill in 60 seconds. See how much you save compared to Firebase,
Auth0, and self-hosted alternatives.
</p>
<div className="flex flex-col sm:flex-row items-center gap-3 pt-2">
<Button asChild size="large" type="primary" iconRight={<ArrowDownIcon className="w-4" />}>
<Link href="#calculator">Calculate my costs</Link>
</Button>
<p className="text-foreground-lighter text-sm">No signup required. Get a shareable report.</p>
</div>
</div>
</SectionContainer>
<div className="pb-6 md:pb-10">
<Logos showHeading />
</div>
<SectionContainer className="!py-10 md:!py-12">
<div className="mx-auto max-w-5xl">
<div className="text-center mb-8 md:mb-10">
<h2 className="text-foreground text-3xl">Four stages to your estimate</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 md:gap-4">
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
<p className="text-foreground text-sm font-medium">Stage 1: Project basics</p>
<p className="text-foreground-lighter text-sm">
Number of environments, team size, and where you&apos;re coming from.
</p>
</Panel>
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
<p className="text-foreground text-sm font-medium">Stage 2: Usage estimation</p>
<p className="text-foreground-lighter text-sm">
Database size, monthly users, bandwidth, and realtime needs.
</p>
</Panel>
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
<p className="text-foreground text-sm font-medium">Stage 3: Growth projections</p>
<p className="text-foreground-lighter text-sm">
See how costs change over 12 or 36 months as you grow.
</p>
</Panel>
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
<p className="text-foreground text-sm font-medium">Stage 4: Time allocation (optional)</p>
<p className="text-foreground-lighter text-sm">
Add detail to value analysis by estimating time spent on infrastructure today.
</p>
</Panel>
</div>
</div>
</SectionContainer>
<SectionContainer id="calculator" className="!pt-10 md:!pt-12">
<PricingCalculator />
</SectionContainer>
</DefaultLayout>
)
}
+60
View File
@@ -780,6 +780,62 @@ export interface WwwPricingPlanCtaClickedEvent {
}
}
/**
* User changed stage in the pricing calculator.
*
* @group Events
* @source www
* @page /pricing-calculator
*/
export interface WwwPricingCalculatorStageChangedEvent {
action: 'www_pricing_calculator_stage_changed'
properties: {
stage: 'stage1' | 'stage2' | 'stage3' | 'stage4'
}
}
/**
* User copied the shareable link in the pricing calculator.
*
* @group Events
* @source www
* @page /pricing-calculator
*/
export interface WwwPricingCalculatorShareLinkCopiedEvent {
action: 'www_pricing_calculator_share_link_copied'
properties: {
stage: 'stage1' | 'stage2' | 'stage3' | 'stage4'
}
}
/**
* User viewed the final report section in the pricing calculator.
*
* @group Events
* @source www
* @page /pricing-calculator
*/
export interface WwwPricingCalculatorReportViewedEvent {
action: 'www_pricing_calculator_report_viewed'
properties: {
recommendedPlan: string
}
}
/**
* User clicked talk-to-sales CTA in the pricing calculator.
*
* @group Events
* @source www
* @page /pricing-calculator
*/
export interface WwwPricingCalculatorTalkToSalesClickedEvent {
action: 'www_pricing_calculator_talk_to_sales_clicked'
properties: {
recommendedPlan: string
}
}
/**
* User clicked the main CTA button in an event page.
*
@@ -2786,6 +2842,10 @@ export type TelemetryEvent =
| HomepageFrameworkQuickstartClickedEvent
| HomepageProductCardClickedEvent
| WwwPricingPlanCtaClickedEvent
| WwwPricingCalculatorStageChangedEvent
| WwwPricingCalculatorShareLinkCopiedEvent
| WwwPricingCalculatorReportViewedEvent
| WwwPricingCalculatorTalkToSalesClickedEvent
| EventPageCtaClickedEvent
| HomepageGitHubButtonClickedEvent
| HomepageDiscordButtonClickedEvent
+6 -1
View File
@@ -3,6 +3,7 @@ import extensions from './extensions.json'
import logConstants from './logConstants'
import { plans, PricingInformation } from './plans'
import { pricing } from './pricing'
import { pricingCalculatorVersion, supabasePricingModel } from './pricing-calculator'
import { PRODUCT_MODULES, products } from './products'
import questions from './questions'
import type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions'
@@ -17,10 +18,14 @@ export {
logConstants,
plans,
pricing,
pricingCalculatorVersion,
supabasePricingModel,
PRODUCT_MODULES,
products,
questions,
topTweets,
tweets,
}
export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region }
export type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions'
export type { PricingInformation } from './plans'
export type { PricingCalculatorPlan, SupabasePricingModel, UsageDimensionKey } from './pricing-calculator'
+178
View File
@@ -0,0 +1,178 @@
import type { PlanId } from './plans'
export type PricingCalculatorCurrency = 'USD'
export type UsageDimensionKey =
| 'projects'
| 'compute'
| 'database_size_gb'
| 'storage_size_gb'
| 'egress_gb'
| 'mau'
| 'sso_mau'
| 'realtime_peak_connections'
| 'realtime_messages'
| 'edge_invocations'
| 'phone_mfa'
export type PricingCalculatorPlan = Exclude<PlanId, 'enterprise'>
export type SupabasePricingModel = {
version: string
currency: PricingCalculatorCurrency
plans: Record<
PricingCalculatorPlan,
{
subscriptionMonthlyUsd: number
included: {
// Quotas / allowances
databaseSizeGbPerProject: number
storageSizeGbPerOrg: number
egressGbPerOrg: number
mauPerOrg: number
// Auth SAML 2.0 (end-user SSO)
ssoMauPerOrg: number
realtimePeakConnectionsPerOrg: number
realtimeMessagesPerOrg: number
edgeInvocationsPerOrg: number
// Free plan project allowance (projects are a “limit”, not an overage line item)
activeProjectsLimit?: number
}
overage: {
databaseSizeUsdPerGb: number
storageSizeUsdPerGb: number
egressUsdPerGb: number
mauUsdPerMau: number
ssoMauUsdPerMau: number
realtimePeakConnectionsUsdPer1000: number
realtimeMessagesUsdPerMillion: number
edgeInvocationsUsdPerMillion: number
}
availability: {
sso: boolean
}
}
>
credits: {
computeCreditsMonthlyUsd: number
appliesToPlans: PricingCalculatorPlan[]
}
addons: {
// Advanced Multi-Factor Auth - Phone
phoneMfa: {
monthlyUsdFirstProject: number
monthlyUsdPerAdditionalProject: number
appliesToPlans: PricingCalculatorPlan[]
}
}
}
/**
* Versioned pricing snapshot for the pricing calculator.
*
* Sources:
* - `apps/docs/content/guides/platform/billing-on-supabase.mdx` (quotas + overage rates)
* - `apps/docs/content/guides/platform/billing-faq.mdx` (compute credits)
* - `packages/shared-data/plans.ts` (subscription plan fees)
* - `packages/shared-data/pricing.ts` (phone MFA add-on pricing)
*/
export const pricingCalculatorVersion = 'v1'
export const supabasePricingModel: SupabasePricingModel = {
version: pricingCalculatorVersion,
currency: 'USD',
plans: {
free: {
subscriptionMonthlyUsd: 0,
included: {
activeProjectsLimit: 2,
databaseSizeGbPerProject: 0.5,
storageSizeGbPerOrg: 1,
egressGbPerOrg: 5,
mauPerOrg: 50_000,
ssoMauPerOrg: 0,
realtimePeakConnectionsPerOrg: 200,
realtimeMessagesPerOrg: 2_000_000,
edgeInvocationsPerOrg: 500_000,
},
overage: {
// Free plan overages are blocked/restricted; we still keep numeric values as 0.
databaseSizeUsdPerGb: 0,
storageSizeUsdPerGb: 0,
egressUsdPerGb: 0,
mauUsdPerMau: 0,
ssoMauUsdPerMau: 0,
realtimePeakConnectionsUsdPer1000: 0,
realtimeMessagesUsdPerMillion: 0,
edgeInvocationsUsdPerMillion: 0,
},
availability: {
sso: false,
},
},
pro: {
subscriptionMonthlyUsd: 25,
included: {
databaseSizeGbPerProject: 8,
storageSizeGbPerOrg: 100,
egressGbPerOrg: 250,
mauPerOrg: 100_000,
ssoMauPerOrg: 50,
realtimePeakConnectionsPerOrg: 500,
realtimeMessagesPerOrg: 5_000_000,
edgeInvocationsPerOrg: 2_000_000,
},
overage: {
databaseSizeUsdPerGb: 0.125,
storageSizeUsdPerGb: 0.021,
egressUsdPerGb: 0.09,
mauUsdPerMau: 0.00325,
ssoMauUsdPerMau: 0.015,
realtimePeakConnectionsUsdPer1000: 10,
realtimeMessagesUsdPerMillion: 2.5,
edgeInvocationsUsdPerMillion: 2,
},
availability: {
sso: true,
},
},
team: {
subscriptionMonthlyUsd: 599,
included: {
databaseSizeGbPerProject: 8,
storageSizeGbPerOrg: 100,
egressGbPerOrg: 250,
mauPerOrg: 100_000,
ssoMauPerOrg: 50,
realtimePeakConnectionsPerOrg: 500,
realtimeMessagesPerOrg: 5_000_000,
edgeInvocationsPerOrg: 2_000_000,
},
overage: {
databaseSizeUsdPerGb: 0.125,
storageSizeUsdPerGb: 0.021,
egressUsdPerGb: 0.09,
mauUsdPerMau: 0.00325,
ssoMauUsdPerMau: 0.015,
realtimePeakConnectionsUsdPer1000: 10,
realtimeMessagesUsdPerMillion: 2.5,
edgeInvocationsUsdPerMillion: 2,
},
availability: {
sso: true,
},
},
},
credits: {
computeCreditsMonthlyUsd: 10,
appliesToPlans: ['pro', 'team'],
},
addons: {
phoneMfa: {
monthlyUsdFirstProject: 75,
monthlyUsdPerAdditionalProject: 10,
appliesToPlans: ['pro', 'team'],
},
},
}