Implement safe, prorated subscription upgrades in Next.js using Server Actions while avoiding double-charges and race conditions.

Why Prorated Upgrades Require Explicit Control

Stripe automatically prorates when you change a subscription’s price or quantity, but the default behavior can create unexpected invoices or pending items if not configured precisely. Setting proration_behavior to ‘create_prorations’ ensures immediate line items while allowing you to control when the customer is charged.

Next.js Server Actions give you a single round-trip from the client component to the server, which is ideal for upgrades because you can validate the user, call the Stripe API, and return the updated subscription state without exposing keys.

The critical decision is whether to invoice immediately or let the proration sit until the next billing cycle; most SaaS products invoice immediately on upgrade to reduce churn risk.

Stripe Client Setup and Type Safety

Create a server-only Stripe instance using the official library and environment variables. Never import this module into client components.

import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
  typescript: true,
});

Define a narrow input type for the Server Action that only accepts the new price ID and subscription ID; this prevents clients from injecting arbitrary Stripe parameters.

Writing the Upgrade Server Action

The action first retrieves the current subscription to confirm ownership, then calls subscriptions.update with the new items and explicit proration settings.

'use server';

export async function upgradeSubscription(
  subscriptionId: string,
  newPriceId: string
) {
  const { data: { user } } = await supabase.auth.getUser();
  // ownership check omitted for brevity
  const sub = await stripe.subscriptions.update(subscriptionId, {
    items: [{ id: existingItemId, price: newPriceId }],
    proration_behavior: 'create_prorations',
    payment_behavior: 'pending_if_incomplete',
  });
  return sub;
}

Use payment_behavior: ‘pending_if_incomplete’ so that a failed immediate charge does not cancel the subscription; you can surface the invoice to the user instead.

Previewing the Proration Amount Before Confirmation

Call the upcoming invoice endpoint with subscription_proration_date to show the exact charge the user will see. This prevents surprise bills and reduces support tickets.

Store the preview result in a short-lived server cache keyed by user ID so the confirmation action can re-validate the same numbers before committing the change.

Idempotency and Race Condition Prevention

Combine the Server Action with a Postgres unique constraint on (subscription_id, event_id) so that rapid double-clicks or retries never create duplicate invoice items. See How to Implement Idempotent Webhooks with Postgres Unique Constraints for the exact constraint pattern.

Pass an idempotency key derived from the user and target price to the Stripe update call; this protects against transient network failures that would otherwise create multiple proration invoices.

Webhook Reconciliation After the Upgrade

Even with Server Actions, listen for invoice.payment_succeeded and customer.subscription.updated events to keep your local database consistent. The same idempotency table used in the action can deduplicate webhook deliveries.

If you also run Paddle or Square, the same pattern appears in How to Sync Paddle Subscriptions to Supabase via Webhooks and How to Sync Square POS Orders to Postgres via Webhooks.

Production Hardening and Cost Controls

Rate-limit the Server Action per user and per minute. Add a maximum upgrade frequency check in the database to block abuse that could generate many small prorated invoices.

Monitor the ‘pending’ invoice state returned by the action; surface a clear retry or update-payment-method flow rather than letting the subscription enter an incomplete state that Stripe will eventually cancel.