Codapult
PricingPluginsBlogDocsDemo

The SaaS Boilerplate for Builders

© 2026 Codapult. All rights reserved.

Built with Codapult

Product

  • Pricing
  • Plugins
  • Blog
  • Documentation
  • SaaS template comparison

About

  • Contact

Legal

  • Privacy Policy
  • Terms of Service
Back to blog
June 2, 2026·4 min read·Codapult Team

The Payment Adapter Pattern in Next.js: Stripe, LemonSqueezy, and Polar Without App-Wide Rewrites

How to structure payment code so checkout, webhooks, billing portals, and marketing checkout can switch providers without touching every page.

nextjspaymentsarchitecture

Most SaaS apps start with one payment provider.

That is fine. The mistake is letting that provider become the architecture.

If Stripe-specific objects leak into pages, server actions, admin tools, and webhook handlers, switching to LemonSqueezy or Polar becomes a rewrite. Even adding one-time products next to subscriptions can become awkward.

The payment adapter pattern keeps provider details behind a small interface.

The Shape of the Problem

Payment code usually appears in several places:

  • Pricing page CTAs.
  • Authenticated checkout.
  • Marketing checkout for one-time products.
  • Billing portal links.
  • Webhooks.
  • Admin subscription views.
  • Refunds.
  • Seat changes.
  • Usage credits.

If each layer imports a provider SDK directly, every feature becomes provider-aware.

That makes the code harder to test and harder to change.

A Small Interface Is Enough

You do not need a large abstraction. Start with the operations the app actually performs:

export interface PaymentAdapter {
  createCheckout(params: CheckoutParams): Promise<string>;
  createMarketingCheckout(params: MarketingCheckoutParams): Promise<string>;
  createPortalSession(customerId: string): Promise<string>;
  handleWebhook(payload: string, signature: string): Promise<WebhookResult>;
  refundPayment(params: RefundParams): Promise<RefundResult>;
}

The rest of the app imports getPaymentAdapter() instead of stripe, lemonsqueezy, or polar.

const adapter = await getPaymentAdapter();
const checkoutUrl = await adapter.createCheckout({
  planId: 'pro',
  interval: 'monthly',
  seats: 5,
  customerId,
});

That one boundary gives you room to change providers without changing every caller.

Keep Plans Provider-Neutral

Plan definitions should describe your product, not your provider.

export const plans = [
  {
    id: 'pro',
    name: 'Pro',
    price: { monthly: 19, yearly: 190 },
    limits: { projects: 'unlimited', aiCredits: 5000 },
  },
] as const;

Provider-specific IDs belong in environment variables or provider mapping code:

PAYMENT_PROVIDER="stripe"
STRIPE_PRICE_PRO_MONTHLY="price_..."

# or
PAYMENT_PROVIDER="polar"
CHECKOUT_VARIANT_PRO="..."

This lets the app reason about pro while the adapter handles provider IDs.

Marketing Checkout Is Different

Authenticated SaaS checkout usually attaches a customer, organization, subscription, and return URL.

Marketing checkout may sell source-code licenses or plugins before the buyer has an account.

That path needs its own method:

await adapter.createMarketingCheckout({
  productKey: 'plugin-ai-kit',
  successUrl,
  cancelUrl,
});

The pricing page can route to /api/checkout?product=plugin-ai-kit, and the route can call the active adapter.

If you prefer direct hosted checkout URLs, keep those as the simpler path:

CHECKOUT_URL_PLUGIN_AI_KIT="https://provider.example/buy/plugin-ai-kit"

In Codapult, CHECKOUT_VARIANT_* wins over CHECKOUT_URL_*, so API checkout is used when a provider variant exists and direct links remain available as fallback.

Webhooks Need Normalized Events

Provider webhooks are not interchangeable.

Stripe, LemonSqueezy, and Polar use different event names, payload shapes, signature headers, and product identifiers. The adapter should normalize those into app-level events.

type WebhookResult =
  | { type: 'subscription.updated'; subscriptionId: string; customerId: string }
  | { type: 'subscription.deleted'; subscriptionId: string }
  | { type: 'payment.refunded'; paymentId: string }
  | { type: 'ignored' };

The route validates rate limits and signature, then passes the normalized result to application code.

What Not to Abstract

Do not hide every provider feature.

Stripe Connect, LemonSqueezy licensing, and Polar's Merchant of Record model are not identical. If you need a provider-specific feature, keep it explicit at the edge of the adapter.

Good adapters remove duplication. Bad adapters pretend different products are the same.

The Payoff

With a payment adapter:

  • Pages do not import provider SDKs.
  • Server actions stay provider-neutral.
  • Webhook routes normalize events.
  • Tests can mock the interface.
  • Marketing checkout can use direct URLs or provider sessions.
  • Switching provider is a config and adapter task, not an app rewrite.

That is the useful level of abstraction: small enough to understand, strong enough to protect the app.


Codapult includes Stripe, LemonSqueezy, and Polar payment adapters; the payments docs show the provider switch and checkout paths.