Codapult
DocsBlogPricingPluginsDemo
Get Codapult
Logo

Build your SaaS with AI. Keep the architecture under control.

Get Codapult

Product

  • Pricing
  • Architecture
  • Modules
  • AI platform
  • Plugins

Developers

  • MCP
  • CLI
  • Documentation
  • Launch your SaaS
  • Build a B2B SaaS

Resources

  • Blog
  • FAQ

Connect

  • Contact
  • GitHub

Compare

  • SaaS template comparison
  • Codapult vs Supastarter
  • Codapult vs Makerkit
  • Codapult vs ShipFast
  • Codapult vs SaaSBold
  • Codapult vs Gravity
  • Codapult vs Nextbase
  • Codapult vs BuilderKit

Featured on

Codapult on LaunchNestCodapult on LaunchNestbetterlaunch.cobetterlaunch.coFeatured on LaunchBuffFeatured on LaunchBuffCodapult on PeerPushCodapult on PeerPushFeatured on LaunchItFeatured on LaunchIt
© 2026 Codapult·All rights reserved·Privacy Policy·Terms of Service
Full source code · One-time purchase · Self-host anywhere
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 tends to sprawl fast: pricing CTAs, authenticated checkout, one-time marketing checkout, billing portal links, webhooks, refunds, seat changes, usage credits. Import a provider SDK directly in even half of these, and every feature becomes provider-aware — which means every provider migration becomes an app-wide grep.

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>;
  createGuestCheckout(params: GuestCheckoutParams): 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. Keep their names, prices, billing intervals, and entitlements in your application's plan source of truth; map provider-specific product and price identifiers at the adapter boundary.

Keep provider-specific product and price identifiers in server-side configuration or provider mapping code. Do not expose secrets or private provider identifiers to the browser.

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:

Pricing
await adapter.createGuestCheckout({
  productKey: 'your-product-key',
  successUrl,
  cancelUrl,
});

Your pricing page can send a stable product key to a server route, which resolves it to the active provider's product and creates the guest checkout session.

If you use provider-hosted checkout URLs instead, keep their mapping in server-side configuration and validate the destination before redirecting.

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 this boundary in place, pages don't import provider SDKs, webhook routes normalize events before they hit application code, and tests mock one interface instead of three. Switching providers becomes a config change and an adapter update — not a rewrite.


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