AIPricingLabGuide · 10 min read
Guide · 10 min read

Build a freemium image generator: end-to-end tutorial

Ship a freemium AI image generator with hard caps, upgrade prompts, real-time analytics, and a live "renders left" badge - using @vevee/sdk + your image provider of choice. Full code, ten-minute build.

Last updated: 2026-05-10

This guide builds a complete freemium image generator: free users get 20 renders/month, pro users get 500, hard cap on free with an upgrade prompt, live "X renders left" badge in the UI, and Stripe-driven plan upgrades.

The whole thing is about 80 lines of code. Most of the complexity that AI image apps drown in - counters, period resets, atomic enforcement, dashboards - is delegated to AIPricingLab.

Step-by-step

1. Set up plans and limit groups

In the AIPricingLab dashboard, create two plans on your app: plan_free (20 image.render / month, calendar-anchored) and plan_pro (500 image.render / month). Match rule on both: event_type = "image.render".

2. Install the SDK and initialize

Backend uses sk_live_, frontend uses pk_live_.

// server.ts
import { createClient } from "@vevee/sdk";
export const vevee = createClient({ apiKey: process.env.VEVEE_KEY! });

// browser.ts
import { createClient } from "@vevee/sdk";
export const aplPub = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });

3. Assign a free plan on signup

When a user signs up, give them the free plan. Idempotent - safe to call repeatedly.

await vevee.upsertSubscription({
  userId: user.id,
  planId: "plan_free",
});

4. Gate image generation with reserve / commit / release

Standard pattern. Reserve before the Flux call; commit on success; release on error.

import { VeveeError } from "@vevee/sdk";

export async function generate(userId: string, prompt: string) {
  const r = await vevee.reserve(userId, "image.render", 1, { model: "flux-pro" });
  if (!r.allowed) {
    return { error: "upgrade_required", reasons: r.reasons };
  }
  try {
    const image = await callFluxPro(prompt);
    await vevee.commit(r.reservationId!);
    return { image };
  } catch (err) {
    await vevee.release(r.reservationId!);
    throw err;
  }
}

5. Render a live "renders left" badge

Frontend uses the public key to read the user's own counters. No server roundtrip needed.

import { useEffect, useState } from "react";
import { aplPub } from "./browser";

export function RendersLeftBadge({ userId }: { userId: string }) {
  const [remaining, setRemaining] = useState<number | null>(null);

  useEffect(() => {
    aplPub.usage(userId).then(u => {
      const c = u.counters.find(x => x.label === "Renders");
      setRemaining(c?.remaining ?? 0);
    });
  }, [userId]);

  if (remaining === null) return null;
  return (
    <span>
      {remaining > 0 ? `${remaining} renders left` : "Upgrade for more renders"}
    </span>
  );
}

6. Catch limit_reached and show an upgrade modal

When the user hits their cap, the reserve call returns allowed=false. Render an upgrade prompt with a Stripe checkout button.

async function onGenerate(prompt: string) {
  const res = await fetch("/api/generate", { method: "POST", body: JSON.stringify({ prompt }) });
  const data = await res.json();

  if (data.error === "upgrade_required") {
    setShowUpgradeModal(true);
    return;
  }
  setImage(data.image);
}

7. On Stripe upgrade, switch the plan

In your Stripe webhook handler, on checkout.session.completed, switch the user to plan_pro. Idempotent - safe on retries.

// /api/stripe/webhook.ts
async function onCheckoutComplete(stripeUserId: string, internalUserId: string) {
  await vevee.upsertSubscription({
    userId: internalUserId,
    planId: "plan_pro",
  });
}

Closing the cycling exploit

Without protection, a user could free-trial → upgrade → cancel → free-trial again to keep getting fresh quotas. Set onPlanChange: "block" on the renders limit group: when they downgrade back to free mid-period, their counter pre-fills to quota and stays there until the next period.

Adding upgrade nudges at 80%

Best-in-class freemium nudges users at ~80% of quota, not 100%. Configure threshold webhooks in the AIPricingLab dashboard at 0.8 of any limit group; wire the webhook to your in-app notification system.

Per-model sub-quotas

Want pro users to get 500 SD renders + 50 Flux Pro renders? Add a second limit group "premium_renders" with match rule { model: ["flux-pro", "imagen-3"] } and quota 50. One render event hits both groups.

Multi-tenant: same code, different plans

If you sell this image generator as a white-label SaaS, every tenant is a separate AIPricingLab workspace. Free / pro plans live per-workspace and inherit your limit-group definitions via templates.

Frequently asked questions

How long does this actually take to build end-to-end?

About ten minutes for the AIPricingLab integration. The Stripe checkout + webhook usually adds another 30 minutes. The hardest part is design / copy for the upgrade modal.

Can I do this without Stripe?

Yes - the free tier with hard caps works completely standalone. Add Stripe (or any other billing system) when you are ready to charge.

What if I want unlimited renders on pro?

Set the pro plan limit group quota to a very large number, or omit the limit group from plan_pro entirely. Either approach works.

How do I handle abuse where one user creates many accounts to stack free tiers?

AIPricingLab does not solve that - it is your auth/identity problem. Pair email-OTP signups with a dedup pass on phone number, GitHub OAuth, or a fingerprint signal. Common in practice.

Other guides