Step-by-step guide to migrate a Next.js SaaS from Stripe to Polar: payments abstraction, webhooks, sandbox, and shadow deploy.
If you have an indie SaaS selling to Europe, you've probably felt the pull to migrate from Stripe to Polar. The main reason: Polar is a Merchant of Record and takes VAT off your hands.
The technical migration isn't as painful as it sounds. If your Stripe integration is well modularized, it's ~2 hours of work. This guide explains how we'd do it in an existing Next.js app.
That's why migration works best with a young SaaS (< 100 subscriptions) or with a gradual switch: new customers on Polar, old ones stay on Stripe until churn.
bun add @polar-sh/sdkKeep stripe for now; you'll remove it at the end.
Keep Stripe's and add Polar's:
POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=productionReplicate the products you have in Stripe. Watch out for:
If you already have an abstraction layer like PaymentService or lib/payments/, you've got this
done. If not, now is the time.
src/lib/payments/types.ts:
export interface PaymentProvider {
createCheckout(params: CheckoutParams): Promise<{ url: string }>;
cancelSubscription(subscriptionId: string): Promise<void>;
verifyWebhook(body: string, signature: string): Promise<WebhookEvent>;
}Implement StripeProvider and PolarProvider. Switch between them with an env var:
const provider =
process.env.PAYMENT_PROVIDER === 'polar' ? new PolarProvider() : new StripeProvider();Stripe and Polar sign webhooks differently. Create a new endpoint, don't modify the Stripe one:
src/app/api/webhooks/polar/route.ts:
import { polar } from '@/lib/polar/client';
import { db } from '@/lib/db/client';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('webhook-signature')!;
const event = polar.webhooks.verify({
body,
signature,
secret: process.env.POLAR_WEBHOOK_SECRET!,
});
// Reuse the logic from your Stripe webhook, adapted to Polar events
switch (event.type) {
case 'subscription.created':
// create in DB
break;
case 'subscription.canceled':
// mark as canceled
break;
}
return new Response('OK');
}In your DB, add a paymentProvider field to the subscriptions table:
model Subscription {
id String @id
userId String
paymentProvider String // 'stripe' | 'polar'
externalId String // ID in Stripe or Polar
status String
...
}When a user cancels and re-subscribes, their new subscription goes to Polar. Old ones stay on Stripe until natural churn.
Polar has a separate sandbox. Before touching production:
POLAR_SERVER=sandbox4242 4242 4242 4242When everything works in sandbox → switch to production.
Don't announce the migration on day 1. Deploy Polar to production with:
1. Webhook deduplication: if you replay the same event, make sure your DB doesn't create
duplicate subscriptions. Index by externalId.
2. VAT in visible pricing: if Stripe showed "10€ VAT included", in Polar the customer will see "10€ + VAT" because Polar calculates it. Either drop the net price or raise the visible one. Communicate it.
3. Refunds and disputes: the logic is different. Read Polar's docs before processing your first refund.
Migrating Stripe → Polar is ~2 hours of code if you have the abstraction in place, or ~1 day from scratch. The longest part is testing in sandbox and shadow-deploying.
In return: you forget about VAT, about OSS filings, about the accountant arguing what kind of service a SaaS is. If you sell to Europe, it's worth it.
Subscribe for more tutorials and tips on building products with AI
The actual integration of Tailwind v4 + shadcn 2.3.0 in Next.js 16 App Router. OKLCH, FOUC-free dark mode, Geist with next/font, and the silent hsl(var()) bug that breaks your styles without warning.