From af47a2d010efbd4db29f0b631ec6ba230e56eb2e Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Tue, 10 Mar 2026 10:27:52 -0230 Subject: [PATCH] experiment:pricing feedback iteration on calculator (#42451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature — A/B experiment for a new pricing calculator on the `/pricing` page, gated behind a PostHog feature flag. ## What is the current behavior? The pricing page shows a single compute pricing section (`PricingComputeSection`) to all visitors with no experimentation support. ## What is the new behavior? - Introduces a **pricing calculator A/B experiment** (`pricingCalculatorExperiment`) using PostHog feature flags evaluated client-side. - **Control variant**: Renders the existing `PricingComputeSection` (no change). - **Test variant**: Renders a new `NewPricingComputeSection` with: - A plan selector toggle (Pro / Team) - An interactive compute calculator with slider-based instance sizing - Ability to add/remove multiple project instances - Live monthly cost estimate breakdown (plan + compute - credits) - Expandable compute pricing table with detailed specs - Adds `getFeatureFlag` and `onFeatureFlags` methods to the shared `PostHogClient` for client-side flag evaluation on www pages (where server-side evaluation lacks full person context). ## Additional context - The experiment is scoped to the `/pricing` page only. - New components (`NewPricingComputeSection`, `NewComputePricingCalculator`) are created alongside the existing ones — the control path is completely untouched. - Feature flag evaluation happens client-side because www pages don't have full person context on the server. - Telemetry type `PricingCalculatorExperimentExposedEvent` is added to `telemetry-constants.ts`. --------- Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com> Co-authored-by: Mert YEREKAPAN Co-authored-by: Mert YEREKAPAN <33198490+myerekapan@users.noreply.github.com> --- apps/www/.env.local.example | 4 + apps/www/app/pricing/PricingContent.tsx | 65 +++++- .../Pricing/NewComputePricingCalculator.tsx | 209 ++++++++++++++++++ .../Pricing/NewPricingComputeSection.tsx | 205 +++++++++++++++++ apps/www/public/rss.xml | 7 + packages/common/posthog-client.ts | 51 +++++ packages/common/telemetry-constants.ts | 22 ++ packages/shared-data/plans.ts | 4 +- 8 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 apps/www/components/Pricing/NewComputePricingCalculator.tsx create mode 100644 apps/www/components/Pricing/NewPricingComputeSection.tsx diff --git a/apps/www/.env.local.example b/apps/www/.env.local.example index b2c47601c2f..9300dad6376 100644 --- a/apps/www/.env.local.example +++ b/apps/www/.env.local.example @@ -6,9 +6,13 @@ MISC_USE_SERVICE_ROLE_KEY=secret NEXT_PUBLIC_API_URL=http://localhost:8080/platform NEXT_PUBLIC_DESIGN_SYSTEM_URL=http://localhost:3003 NEXT_PUBLIC_DOCS_URL=http://localhost:3005 +NEXT_PUBLIC_ENVIRONMENT=local NEXT_PUBLIC_HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001 +NEXT_PUBLIC_IS_PLATFORM=true NEXT_PUBLIC_MISC_USE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M NEXT_PUBLIC_MISC_USE_URL=https://obuldanrptloktxcffvn.supabase.co +NEXT_PUBLIC_POSTHOG_HOST=https://ph.supabase.green +NEXT_PUBLIC_POSTHOG_KEY=your-posthog-key-here NEXT_PUBLIC_REFERENCE_DOCS_URL=https://localhost:3010 NEXT_PUBLIC_STUDIO_URL=http://localhost:8082 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NzUwOTQ4MzUsImV4cCI6MTk5MDY3MDgzNX0.0PMlOxtKL4O9GGZuAP_Xl4f-Tut1qOnW4bNEmAtoB8w diff --git a/apps/www/app/pricing/PricingContent.tsx b/apps/www/app/pricing/PricingContent.tsx index 56ec66b8390..9402a4ec3fc 100644 --- a/apps/www/app/pricing/PricingContent.tsx +++ b/apps/www/app/pricing/PricingContent.tsx @@ -1,16 +1,23 @@ 'use client' import { ArrowDownIcon } from '@heroicons/react/outline' -import { ArrowUpRight } from 'lucide-react' -import dynamic from 'next/dynamic' -import { usePathname } from 'next/navigation' -import { useEffect } from 'react' -import { Button } from 'ui' - import DefaultLayout from '~/components/Layouts/Default' import PricingPlans from '~/components/Pricing/PricingPlans' import { useOrganizations } from '~/data/organizations' +import { hasConsented, posthogClient } from 'common' +import { ArrowUpRight } from 'lucide-react' +import dynamic from 'next/dynamic' +import { usePathname } from 'next/navigation' +import { useEffect, useState } from 'react' +import { Button } from 'ui' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +const EXPERIMENT_ID = 'pricingCalculatorExperiment' as const +type PricingCalculatorVariant = 'control' | 'test' + +const NewPricingComputeSection = dynamic( + () => import('~/components/Pricing/NewPricingComputeSection') +) const PricingComputeSection = dynamic(() => import('~/components/Pricing/PricingComputeSection')) const PricingAddons = dynamic(() => import('~/components/Pricing/PricingAddons')) const PricingComparisonTable = dynamic(() => import('~/components/Pricing/PricingComparisonTable')) @@ -45,6 +52,30 @@ export default function PricingContent() { const { isLoading, organizations } = useOrganizations() const hasExistingOrganizations = !isLoading && organizations.length > 0 + // Pricing calculator A/B experiment + // Uses client-side PostHog directly — server-side evaluation lacks full person context for www pages. + // DevToolbar overrides (x-ph-flag-overrides cookie) are respected in local dev via posthogClient.getFeatureFlag. + const [flagValue, setFlagValue] = useState( + () => + posthogClient.getFeatureFlag(EXPERIMENT_ID) as PricingCalculatorVariant | false | undefined + ) + + useEffect(() => { + return posthogClient.onFeatureFlags(() => { + const value = posthogClient.getFeatureFlag(EXPERIMENT_ID) + setFlagValue(value as PricingCalculatorVariant | false | undefined) + }) + }, []) + + const isTestVariant = flagValue === 'test' + const isInExperiment = flagValue === 'control' || flagValue === 'test' + + useEffect(() => { + if (!isInExperiment) return + + posthogClient.captureExperimentExposure(EXPERIMENT_ID, { variant: flagValue }, hasConsented()) + }, [isInExperiment, flagValue]) + return (
@@ -88,7 +119,27 @@ export default function PricingContent() { id="addon-compute" className="container relative mx-auto px-4 lg:px-12 pt-16 md:pt-24 lg:pt-32 lg:pb-16" > - + {isTestVariant && ( +
+

+ How compute pricing works +

+

+ Choose a plan, add projects, and see your total cost +

+
+ + What is “compute”? + + Think of compute as the computer your database runs on. As your app grows, you + scale CPU and memory to handle more traffic and data. + + +
+
+ )} + + {isTestVariant ? : }
+ instance.columns?.find((col: any) => col.key === column)?.value + +const parsePrice = (price: string) => { + const n = parseInt(price?.toString().replace('$', '').replace(',', ''), 10) + return Number.isNaN(n) ? 0 : n +} + +interface ComputePricingCalculatorProps { + activePlan?: { + name: string + price: number + } +} + +const NewComputePricingCalculator = ({ + activePlan = { name: 'Pro', price: 25 }, +}: ComputePricingCalculatorProps) => { + // Filter out rows with no specific pricing + const computeInstances = pricingAddOn.database.rows.filter((row) => + row.columns.some((it) => it.key === 'pricing' && it.value !== 'Contact Us') + ) + const priceSteps = computeInstances.map((instance) => + parsePrice(findInstanceValueByColumn(instance, 'pricing')) + ) + // Base discount credits that come with every paid plan + const COMPUTE_CREDITS = 10 + + const [activeInstances, setActiveInstances] = useState([{ ...computeInstances[0], position: 0 }]) + // Final calculated price: plan cost + compute aggregate - compute credits + const [activePrice, setActivePrice] = useState(activePlan.price + priceSteps[0] - COMPUTE_CREDITS) + const [hasInteractedWithSlider, setHasInteractedWithSlider] = useState(false) + + const handleUpdateInstance = (position: number, value: number[]) => { + setHasInteractedWithSlider(true) + const newArray = activeInstances.map((activeInstance) => { + // only update the instance corresponding to the correct slider + if (activeInstance.position === position) { + return { ...computeInstances[value[0] - 1], position } + } else { + return activeInstance + } + }) + + setActiveInstances(newArray) + } + + const calculateComputeAggregate = (price: number) => { + return activeInstances.reduce( + (acc, activeInstance: any) => + acc + parsePrice(findInstanceValueByColumn(activeInstance, 'pricing')), + price + ) + } + + useEffect(() => { + const computeAggregate = calculateComputeAggregate(0) + setActivePrice(Math.max(0, computeAggregate + activePlan.price - COMPUTE_CREDITS)) + }, [activeInstances, activePlan]) + + const removeInstance = (position: number) => { + const newArray = activeInstances + .filter((activeInstance) => activeInstance.position !== position) + .map((instance, index) => ({ ...instance, position: index })) + + setActiveInstances(newArray) + } + + const findSliderComputeValue = (activeInstance: any) => { + // find index of compute based on active compute name + const selectedCompute = computeInstances + .map((compute) => findInstanceValueByColumn(compute, 'plan')) + .indexOf(findInstanceValueByColumn(activeInstance, 'plan')) + + return [selectedCompute + 1] + } + + return ( +
+
+
+

Monthly estimate:

+
+
+ Plan subscription + + ${activePlan.price} + +
+
+ Total Compute + ${calculateComputeAggregate(0)} +
+
+ Compute Credits + + - ${COMPUTE_CREDITS} + +
+
+
+ Total + + + This estimate only includes Plan and Compute add-on monthly costs. Other resources + might incur additional costs in the final invoice. + + ${activePrice} + +
+
+
+
+
+ {activeInstances.map((activeInstance, index) => ( +
+
+
+ +

+ Project {activeInstance.position + 1} +

+
+ + {findInstanceValueByColumn(activeInstance, 'pricing')} + +
+
+ {!hasInteractedWithSlider && ( + + )} + handleUpdateInstance(activeInstance.position, value)} + value={findSliderComputeValue(activeInstance)} + min={1} + key={`${index}-${activeInstance.position}`} + max={priceSteps.length} + step={1} + className={cn( + 'w-full cursor-grab active:cursor-grabbing', + !hasInteractedWithSlider && "[&_[data-slot='slider-thumb']]:animate-pulse" + )} + /> +
+
+
+ + {findInstanceValueByColumn(activeInstance, 'memory')} RAM /{' '} + {findInstanceValueByColumn(activeInstance, 'cpu')} CPU / Connections: Direct{' '} + {findInstanceValueByColumn(activeInstance, 'directConnections')}, Pooler{' '} + {findInstanceValueByColumn(activeInstance, 'poolerConnections')} + +
+
+ {activeInstances.length > 1 && ( + + )} +
+
+
+ ))} +
+
+ +
+
+
+ ) +} + +export default NewComputePricingCalculator diff --git a/apps/www/components/Pricing/NewPricingComputeSection.tsx b/apps/www/components/Pricing/NewPricingComputeSection.tsx new file mode 100644 index 00000000000..c180ac11a24 --- /dev/null +++ b/apps/www/components/Pricing/NewPricingComputeSection.tsx @@ -0,0 +1,205 @@ +import { ChevronDownIcon } from '@heroicons/react/outline' +import Link from 'next/link' +import React, { useEffect, useRef, useState } from 'react' +import { useWindowSize } from 'react-use' +import { plans as allPlans } from 'shared-data/plans' +import { Button, cn } from 'ui' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { ToggleGroup, ToggleGroupItem } from 'ui/src/components/shadcn/ui/toggle-group' + +import Panel from '../Panel' +import ComputePricingTable from './ComputePricingTable' +import NewComputePricingCalculator from './NewComputePricingCalculator' +import PricingComputeAnimation from './PricingComputeAnimation' + +const plans = allPlans + .filter((plan) => plan.planId === 'pro' || plan.planId === 'team') + .map((plan) => ({ + name: plan.name, + price: plan.priceMonthly as number, + })) + +const NewPricingComputeSection = () => { + const ref = useRef(null) + const [showTable, setShowTable] = useState(false) + const { width } = useWindowSize() + const [height, setHeight] = useState(ref?.current?.clientHeight) + const [activePlan, setActivePlan] = useState(plans[0]) + + useEffect(() => { + setHeight(ref?.current?.clientHeight) + }, [width]) + + return ( + +
+
+
+

1. Choose your plan

+
+ { + const selectedPlan = plans.find((p) => p.name === value) + if (selectedPlan) setActivePlan(selectedPlan) + }} + className="grid grid-cols-2 gap-2 w-full bg-surface-200 rounded-md" + > + {plans.map((plan) => ( + + {plan.name} + + ))} + + +
+

{activePlan.name}

+
+

+ ${activePlan.price} + /month +

+
+
+ {activePlan.name === 'Pro' && ( + <> +

+ Everything in the Free Plan, plus: +

+

100K monthly active users

+

8 GB disk size per project

+

250 GB bandwidth

+

Daily backups (7 day retention)

+

Email support

+ + )} + {activePlan.name === 'Team' && ( + <> +

+ Everything in the Pro Plan, plus: +

+

SOC2

+

Project-scoped and read-only access

+

HIPAA available as paid add-on

+

SSO for Supabase Dashboard

+

Priority email support & SLAs

+

Daily backups (14 day retention)

+ + )} +

+ Paid plans include $10/mo in compute credits, enough to cover one Micro instance. +

+
+
+
+
+

+ 2. Configure compute for your projects +

+

+ Add projects and adjust compute to estimate your monthly cost. +

+ + +
+
+
+
+
+
+

+ + Starts from $10/month + +

+

+ Scale compute up to +
64 cores and 256 GB RAM +

+
+ + +
+
+
+
+ + +
+
+

Choose the best compute size for you

+

+ Every project on the Supabase Platform comes with its own dedicated Postgres + instance. Select the compute size that fits your needs. +

+

+ Compute instances are billed hourly and you can scale up or down at any time. Paid + Plans come with $10/month in compute credits to cover + one Micro instance or offset the cost of any other instance. +

+

+ Read more on{' '} + + usage-based billing for compute + {' '} + or{' '} + + Compute Add-ons + + . +

+
+
+ +
+
+
+
+ +
+ + ) +} + +export default NewPricingComputeSection diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 5092c77217f..18f8f9cccd0 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -13,6 +13,13 @@ Supabase Pro users can now send their Supabase logs to their own logging backend, enabling them to debug in the same place as the rest of their stack. Thu, 05 Mar 2026 00:00:00 -0700 + + https://supabase.com/blog/supabase-storage-performance-security-reliability-updates + Supabase Storage: major performance, security, and reliability updates + https://supabase.com/blog/supabase-storage-performance-security-reliability-updates + Storage ships a rewritten object listing system, path traversal prevention, idempotent migrations, OpenTelemetry metrics, and fixes for the most common reliability issues. + Thu, 05 Mar 2026 00:00:00 -0700 + https://supabase.com/blog/supabase-incident-on-february-12-2026 Supabase incident on February 12, 2026 diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts index 24284840a02..acff08b0b9e 100644 --- a/packages/common/posthog-client.ts +++ b/packages/common/posthog-client.ts @@ -33,6 +33,7 @@ class PostHogClient { private config: PostHogClientConfig private readonly maxPendingEvents = MAX_PENDING_EVENTS private devListeners: Set = new Set() + private pendingFeatureFlagCallbacks: Set<() => void> = new Set() constructor(config: PostHogClientConfig = {}) { const apiHost = @@ -107,6 +108,10 @@ class PostHogClient { this.initStarted = true posthog.init(this.config.apiKey, config) + + // Register any feature flag callbacks that were queued before init + this.pendingFeatureFlagCallbacks.forEach((cb) => posthog.onFeatureFlags(cb)) + this.pendingFeatureFlagCallbacks.clear() } capturePageView(properties: Record, hasConsent: boolean = true) { @@ -247,6 +252,52 @@ class PostHogClient { return undefined } + /** + * Returns a PostHog feature flag value directly from the client-side SDK. + * Use this for www/docs pages where server-side evaluation lacks full person context. + * In local dev, DevToolbar overrides (x-ph-flag-overrides cookie) take priority. + */ + getFeatureFlag(key: string): string | boolean | undefined { + if (typeof document === 'undefined') return undefined + + if (process.env.NODE_ENV === 'development') { + try { + const cookieEntry = document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith('x-ph-flag-overrides=')) + if (cookieEntry) { + const overrides = JSON.parse( + decodeURIComponent(cookieEntry.substring('x-ph-flag-overrides='.length)) + ) + if (key in overrides) return overrides[key] + } + } catch {} + } + + if (!this.initialized) return undefined + + try { + return posthog.getFeatureFlag(key) + } catch { + return undefined + } + } + + /** + * Subscribe to PostHog feature flag loads/reloads. + * Returns an unsubscribe function. + */ + onFeatureFlags(callback: () => void): () => void { + if (!this.initStarted) { + // Queue until init() is called + this.pendingFeatureFlagCallbacks.add(callback) + return () => this.pendingFeatureFlagCallbacks.delete(callback) + } + if (typeof posthog.onFeatureFlags !== 'function') return () => {} + return posthog.onFeatureFlags(callback) ?? (() => {}) + } + /** * Returns PostHog's session_id for the current session. * Returns undefined until PostHog's `loaded` callback fires. diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 4eb26581df4..fffc6a4eede 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2675,6 +2675,27 @@ export interface RlsEventTriggerBannerCreateButtonClickedEvent { groups: TelemetryGroups } +/** + * User was exposed to the pricing calculator experiment on the /pricing page. + * + * @group Events + * @source www + * @page /pricing + */ +export interface PricingCalculatorExperimentExposedEvent { + action: 'pricing_calculator_experiment_exposed' + properties: { + /** + * Experiment identifier for tracking + */ + experiment_id: 'pricingCalculatorExperiment' + /** + * Experiment variant: 'control' (existing compute section) or 'test' (new compute section) + */ + variant: 'control' | 'test' + } +} + /** * User clicked the Run button in the log explorer. * @@ -2842,3 +2863,4 @@ export type TelemetryEvent = | IntegrationInstallFailedEvent | IntegrationUninstallCompletedEvent | RlsEventTriggerBannerCreateButtonClickedEvent + | PricingCalculatorExperimentExposedEvent diff --git a/packages/shared-data/plans.ts b/packages/shared-data/plans.ts index b32860d63c5..82a20684e1d 100644 --- a/packages/shared-data/plans.ts +++ b/packages/shared-data/plans.ts @@ -50,7 +50,7 @@ export const plans: PricingInformation[] = [ costUnit: '/ month', href: 'https://supabase.com/dashboard/new?plan=pro', priceLabel: 'From', - warning: '$10 in compute credits included', + warning: 'Includes one project running on Micro compute.', priceMonthly: 25, description: 'For production applications with the power to scale.', features: [ @@ -75,7 +75,7 @@ export const plans: PricingInformation[] = [ costUnit: '/ month', href: 'https://supabase.com/dashboard/new?plan=team', priceLabel: 'From', - warning: '$10 in compute credits included', + warning: 'Includes one project running on Micro compute.', priceMonthly: 599, description: 'Add features such as SSO, control over backups, and industry certifications.', features: [