Show users your plan features

Your dashboard is already the source of truth for what each plan includes. This guide shows how to render a pricing page from it - so “Free”, “Pro”, and every limit you ship lives in one place and your marketing site never lies about what users get.

The problem with hardcoding plan copy

Every team I've worked with starts the same way: define plans in the dashboard, copy-paste the names and quotas into a const PLANS = [...] on the marketing site. Two weeks later someone bumps the Pro image quota in the dashboard, ships, and the pricing page silently advertises the old number. Users complain. The dashboard and the landing page drift further every release.

The fix is one SDK call: vevee.availablePlans(). It returns the same plan definitions your metering enforces against - so the page can't lie.

Step 1 - get a public key

availablePlans() is safe from the browser, so use a pk_live_ key (test mode? use pk_test_; same data, since plans are shared between modes). Generate one from your app's API keys tab in the dashboard.

import { createClient } from '@vevee/sdk';

export const vevee = createClient({
  apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY!, // pk_live_… is safe in client code
});

Step 2 - render the bare structure

Start by dumping the response straight to the page. Don't worry about pretty copy yet - get the data round-trip working first.

'use client';
import { useEffect, useState } from 'react';
import type { PublicPlan } from '@vevee/sdk';
import { vevee } from '@/lib/vevee';

export function PricingTable() {
  const [plans, setPlans] = useState<PublicPlan[]>([]);

  useEffect(() => {
    vevee.availablePlans().then(setPlans);
  }, []);

  return (
    <div className="grid grid-cols-3 gap-6">
      {plans.map((plan) => (
        <article key={plan.id} className="rounded-lg border p-6">
          <h3 className="text-xl font-semibold">{plan.name}</h3>
          <p className="text-sm text-muted-foreground">
            Resets {plan.period.type}
          </p>
          <ul className="mt-4 space-y-2">
            {plan.limits.map((g) => (
              <li key={g.id}>
                {g.quota.toLocaleString()} {g.label || g.event}
              </li>
            ))}
          </ul>
        </article>
      ))}
    </div>
  );
}

Step 3 - translate event patterns to human copy

The event field on each limit group is the raw pattern your metering keys off - image.*, llm.tokens, video.seconds. Great for engineers, ugly for end-users. Keep a small map next to your UI so engineering can rename events without breaking your copy:

const EVENT_COPY: Record<string, string> = {
  'image.*':              'AI images',
  'image.flux-pro':       'Flux Pro renders',
  'video.*':              'Video seconds',
  'llm.tokens':           'LLM tokens',
};

export function pricingLabel(g: PublicLimitGroup): string {
  // Prefer the dashboard label, fall back to event-pattern copy.
  return g.label || EVENT_COPY[g.event] || g.event;
}

Better: set labeldirectly in the dashboard's plan builder and skip the map entirely. availablePlans() returns whatever you typed there.

Step 4 - surface variant- and source-gated limits

Match rules can carry metadata filters - “image.* with variant: '4k'” or “llm.tokens with model: 'gpt-5'”. These are exactly the differentiators a pricing page lives or dies on. The matchesarray exposes them so you can render “500 Flux Pro images · 4k only” without duplicating those filters in your frontend code.

function describeFilters(matches: MatchRule[]): string {
  // Roll metadata constraints up across the group's rules.
  const acc: Record<string, Set<string>> = {};
  for (const m of matches) {
    if (!m.metadata) continue;
    for (const [k, v] of Object.entries(m.metadata)) {
      (acc[k] ??= new Set()).add(v);
    }
  }
  return Object.entries(acc)
    .map(([k, vals]) => `${k}: ${[...vals].sort().join(', ')}`)
    .join(' · ');
}

// Usage:
// describeFilters(group.matches)  -> "variant: 4k" or "model: gpt-5, gpt-5-mini"

Render it under the quota line:

{plan.limits.map((g) => {
  const filterCopy = describeFilters(g.matches);
  return (
    <li key={g.id}>
      <strong>{g.quota.toLocaleString()}</strong> {pricingLabel(g)}
      {filterCopy && <small className="block text-muted">{filterCopy}</small>}
    </li>
  );
})}

Step 5 - add prices (the one bit Vevee doesn't store)

Plans don't carry a price tag - that's deliberate, since pricing belongs in Stripe (or your CMS). Map plan ids to prices in your frontend:

const PLAN_PRICES: Record<string, { price: string; cta: string }> = {
  plan_free: { price: '$0',     cta: 'Get started' },
  plan_pro:  { price: '$20/mo', cta: 'Subscribe' },
  plan_team: { price: '$99/mo', cta: 'Contact sales' },
};

Or - when you're ready - fetch prices from Stripe and join them in. The IDs from availablePlans() are stable; tag your Stripe products with the Vevee planId in metadata and you have a clean join key.

Step 6 - wire the “Choose” button

The plan id you display is the same one you pass to upsertSubscription(). Most apps run checkout through Stripe and then call upsertSubscription from a webhook:

// Browser: redirect to Stripe with the plan id in metadata.
async function subscribe(planId: string) {
  const res = await fetch('/api/checkout', {
    method: 'POST',
    body: JSON.stringify({ planId }),
  });
  const { url } = await res.json();
  window.location.href = url;
}

// Server (Stripe webhook): commit the plan in Vevee.
if (event.type === 'checkout.session.completed') {
  const session = event.data.object;
  await vevee.upsertSubscription({
    userId: session.client_reference_id!,
    planId: session.metadata.aipricinglab_plan_id!,
  });
}

Server-side rendering and caching

Plans change rarely - your pricing page does not need a client-side fetch on every visit. Render it on the server and cache for a few minutes. Even five minutes of staleness is fine; you control when dashboard changes go live by busting the route cache.

// app/pricing/page.tsx
import { createClient } from '@vevee/sdk';

export const revalidate = 300; // 5 minutes

export default async function PricingPage() {
  const vevee = createClient({ apiKey: process.env.VEVEE_PUBLIC_KEY! });
  const plans = await vevee.availablePlans();
  return <PricingTable plans={plans} />;
}

What about the user's current plan?

availablePlans()tells you what you sell; it doesn't know who the visitor is. For an in-app dashboard that highlights “You're on Pro” and shows the user's remaining quota, combine with vevee.usage(userId): the groupId on each counter lines up with plan.limits[i].id, so you can render the same plan card and overlay the current numbers.

Pitfalls

  • Display order: plans return in dashboard-creation order. Hardcode a display order array if you reshuffle plans frequently - availablePlans() doesn't expose a sort weight today.
  • Empty matches: a limit group authored without a match rule will have event: ''. The metering layer ignores it; your UI probably should too.
  • Don't leak secret keys: only pk_* keys belong in browser code. Secret keys can read internal usage data - keep them server-side.
  • Test mode preview: swap to a pk_test_ key in a staging build to preview your real pricing page against your sandbox setup before shipping plan changes to production users.

What you end up with

  • A pricing page that's impossible to desync from the dashboard.
  • One place to edit plan structure (the dashboard) - marketing copy follows automatically.
  • Variant-aware feature lines (“500 4k images / mo”) without hand-maintained constants.
  • A clean handoff from the pricing card to upsertSubscription() via stable plan ids.

See also