pricing-page

SKILL.md

Scaffold a complete pricing system — tier definitions, feature gating logic, Dodo Payments checkout, and a frontend pricing component. Reads the project first, wires into the existing stack.

Phase 1: Understand the Project

Before writing anything, read the codebase:

1.1 Stack Detection

  • Framework: Next.js / other?
  • Database: What ORM/client? What does the users table look like?
  • Auth: How is the current user identified in API routes?
  • Existing payments: Any Dodo SDK or payment routes already?

1.2 Ask the User

I'll scaffold pricing for your [framework] app.

Quick decisions:

1. How many tiers? (e.g., Free + Pro, or Free + Pro + Enterprise)
2. What's the pricing model? (flat rate / credits / per-seat / usage-based)
3. Monthly billing, annual, or both?
4. What features are gated behind paid? (or let me suggest based on the codebase)

I'll handle Dodo Payments integration.

Phase 2: Tier Definitions

Create a single source of truth for tiers. Adapt based on the user's answers:

// config/pricing.ts

export type Plan = 'free' | 'pro' | 'enterprise';

export const PLANS = {
  free: {
    name: 'Free',
    price: 0,
    description: 'Get started',
    features: ['[Feature 1]', '[Feature 2]'],
    limits: {
      // Fill based on codebase — e.g., creditsPerMonth: 50
    },
    cta: 'Get Started',
    ctaHref: '/signup',
  },
  pro: {
    name: 'Pro',
    priceMonthly: 0, // fill from user
    priceAnnual: 0,  // fill from user
    description: 'For serious users',
    features: ['Everything in Free', '[Pro Feature 1]', '[Pro Feature 2]'],
    limits: {
      // creditsPerMonth: 500, etc.
    },
    cta: 'Upgrade to Pro',
    highlighted: true,
    badge: 'Most Popular',
  },
} as const;

For a credits-based app, add credit pack definitions alongside plan definitions.

Phase 3: Feature Gating

Create a utility that checks access before any gated feature runs. This is the enforcement layer — everything else is display:

// lib/feature-gate.ts

type Plan = 'free' | 'pro' | 'enterprise';

// Define which plans can access which features
// Populate based on what actually exists in the codebase
const FEATURE_ACCESS: Record<string, Plan[]> = {
  'api-access': ['pro', 'enterprise'],
  'export-data': ['pro', 'enterprise'],
  'custom-domain': ['enterprise'],
  'priority-support': ['enterprise'],
};

export function canAccess(userPlan: Plan, feature: string): boolean {
  return FEATURE_ACCESS[feature]?.includes(userPlan) ?? false;
}

In API routes, check before any expensive work:

export async function POST(req: Request) {
  const user = await getAuthUser(req);

  if (!canAccess(user.plan, 'api-access')) {
    return Response.json(
      { error: 'This feature requires Pro.', upgradeUrl: '/pricing' },
      { status: 403 }
    );
  }

  // ... rest of handler
}

In UI, show the locked state rather than hiding the feature. Users need to know the feature exists:

function ExportButton({ userPlan }: { userPlan: Plan }) {
  if (!canAccess(userPlan, 'export-data')) {
    return (
      <button
        onClick={() => router.push('/pricing')}
        className="opacity-60"
        title="Upgrade to Pro to export"
      >
        šŸ”’ Export — Pro only
      </button>
    );
  }
  return <button onClick={handleExport}>Export</button>;
}

Phase 4: Dodo Payments Integration

Environment Variables

DODO_API_KEY=          # From Dodo dashboard
DODO_WEBHOOK_SECRET=   # whsec_... format — see /dodo-webhook skill
DODO_PRODUCT_ID=       # Product ID for Pro plan
APP_URL=               # Frontend URL for checkout redirect

Install: npm install @dodopayments/sdk

Checkout Creation Endpoint

// app/api/payments/create-checkout/route.ts
import DodoPayments from '@dodopayments/sdk';

const dodo = new DodoPayments({ bearerToken: process.env.DODO_API_KEY });

export async function POST(req: Request) {
  const user = await getAuthUser(req);
  const { planId } = await req.json();

  const checkout = await dodo.payments.create({
    payment_link: true,
    customer: { email: user.email },
    product_cart: [{ product_id: process.env.DODO_PRODUCT_ID!, quantity: 1 }],
    metadata: {
      userId: user.id,   // REQUIRED — webhook uses this
      planId,            // which plan they're buying
    },
    return_url: `${process.env.APP_URL}/checkout/success?plan=${planId}`,
  });

  return Response.json({ checkout_url: checkout.payment_link });
}

The metadata is how your webhook finds the user. If userId isn't in metadata, the webhook can't update the right account. Use the /dodo-webhook skill to wire the webhook handler.

Customer Portal

Link users to Dodo's hosted billing portal for plan management, cancellation, and invoice history:

// app/api/billing/portal/route.ts
export async function GET(req: Request) {
  const user = await getAuthUser(req);

  const portal = await dodo.customerPortal.create({
    customer_id: user.dodoCustomerId,
    return_url: `${process.env.APP_URL}/settings/billing`,
  });

  return Response.json({ portal_url: portal.url });
}

Add a "Manage billing" link in user settings that hits this endpoint.

Checkout Success Page

Create /checkout/success — this is the page Dodo redirects to after payment. The webhook may arrive a few seconds after the redirect, so poll for the updated plan:

// app/checkout/success/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

export default function CheckoutSuccess() {
  const [plan, setPlan] = useState<string | null>(null);
  const router = useRouter();

  useEffect(() => {
    // Poll until plan updates (webhook may lag by a few seconds)
    let attempts = 0;
    const interval = setInterval(async () => {
      const res = await fetch('/api/auth/me');
      const user = await res.json();
      if (user.plan !== 'free' || attempts > 10) {
        setPlan(user.plan);
        clearInterval(interval);
      }
      attempts++;
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  if (!plan) return <p>Confirming your upgrade...</p>;

  return (
    <div>
      <h1>You're on {plan}.</h1>
      <p>Your account has been upgraded.</p>
      <a href="/dashboard">Go to dashboard →</a>
    </div>
  );
}

Phase 5: Pricing UI Component

Generate a responsive pricing component. The design must emphasize one tier — users who see three equal-weight tiers often leave without deciding:

// components/pricing-cards.tsx
'use client';

import { PLANS } from '@/config/pricing';

interface PricingCardsProps {
  currentPlan?: string;
  onUpgrade?: (planId: string) => void;
}

export function PricingCards({ currentPlan, onUpgrade }: PricingCardsProps) {
  const handleUpgrade = async (planId: string) => {
    const res = await fetch('/api/payments/create-checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ planId }),
    });
    const { checkout_url } = await res.json();
    window.location.href = checkout_url;
  };

  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
      {Object.entries(PLANS).map(([planId, plan]) => (
        <div
          key={planId}
          className={`rounded-xl border p-6 ${
            'highlighted' in plan && plan.highlighted
              ? 'border-black shadow-xl'
              : 'border-gray-200'
          }`}
        >
          {'badge' in plan && plan.badge && (
            <span className="text-xs font-bold uppercase tracking-widest text-black">
              {plan.badge}
            </span>
          )}
          <h3 className="mt-2 text-xl font-bold">{plan.name}</h3>
          <p className="mt-1 text-3xl font-bold">
            {'price' in plan ? (plan.price === 0 ? 'Free' : `$${plan.price}/mo`) : `$${plan.priceMonthly}/mo`}
          </p>
          <ul className="mt-4 space-y-2">
            {plan.features.map((f) => (
              <li key={f} className="flex items-center gap-2 text-sm text-gray-700">
                <span className="text-green-500">āœ“</span> {f}
              </li>
            ))}
          </ul>
          <div className="mt-6">
            {currentPlan === planId ? (
              <div className="py-2 text-center text-sm text-gray-400">Current plan</div>
            ) : (
              <button
                onClick={() => handleUpgrade(planId)}
                className={`w-full rounded-lg py-2 text-sm font-medium ${
                  'highlighted' in plan && plan.highlighted
                    ? 'bg-black text-white'
                    : 'border border-gray-300 text-gray-700 hover:border-black'
                }`}
              >
                {plan.cta}
              </button>
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

Phase 6: Verify

Flow 1: Feature Gating
[ ] Free user hits gated endpoint → 403 with upgradeUrl
[ ] Pro user hits same endpoint → proceeds normally
[ ] Gated UI shows locked state, links to /pricing

Flow 2: Checkout
[ ] "Upgrade" button creates checkout session
[ ] Redirects to Dodo-hosted checkout page
[ ] userId is in checkout metadata
[ ] After payment, redirects to /checkout/success

Flow 3: Webhook (handled by /dodo-webhook skill)
[ ] Webhook verified and processed
[ ] User plan updated in database
[ ] Success page reflects new plan after polling

Flow 4: Billing Portal
[ ] "Manage billing" link accessible in settings
[ ] Opens Dodo customer portal
[ ] Returns to app after portal actions

Flow 5: Edge Cases
[ ] Users without dodoCustomerId don't crash portal link
[ ] Checkout success polling stops after plan updates
[ ] Env vars in .env.example, not hardcoded

See references/guide.md for pricing psychology, A/B test ideas, and advanced feature gating patterns.

Weekly Installs
4
First Seen
11 days ago
Installed on
opencode4
gemini-cli4
claude-code4
github-copilot4
codex4
kimi-cli4