Complete technical guide to integrating Polar into a Next.js app: SDK, checkout, webhook, local testing, and common errors.
Polar is one of the two most-used payment gateways in indie SaaS in 2026 (the other one is Stripe). Its big advantage: as Merchant of Record, it handles VAT for you. If you sell to Europe, it saves headaches.
This guide explains how to integrate Polar in a Next.js app step by step. It assumes you already have a Polar account and a product created.
bun add @polar-sh/sdkIn your .env.local:
POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=productionFor development, use POLAR_SERVER=sandbox. Sandbox keys are different, configure them in a
.env.development.local.
src/lib/polar/client.ts:
import { Polar } from '@polar-sh/sdk';
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.POLAR_SERVER as 'production' | 'sandbox',
});src/app/api/checkout/route.ts:
import { polar } from '@/lib/polar/client';
import { auth } from '@/lib/auth/server';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { productId } = await req.json();
const checkout = await polar.checkouts.create({
productId,
customerEmail: session.user.email,
successUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success`,
});
return NextResponse.json({ url: checkout.url });
}Polar notifies you via webhook when someone pays, cancels, or when a subscription changes. Without a properly configured webhook, you don't find out and your DB goes out of sync.
src/app/api/webhooks/polar/route.ts:
import { polar } from '@/lib/polar/client';
import { db } from '@/lib/db/client';
import { headers } from 'next/headers';
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get('webhook-signature');
// Verify signature
const event = polar.webhooks.verify({
body,
signature: signature!,
secret: process.env.POLAR_WEBHOOK_SECRET!,
});
switch (event.type) {
case 'subscription.created':
await db.subscription.create({
data: {
userId: event.data.metadata.userId,
polarSubscriptionId: event.data.id,
status: 'active',
},
});
break;
case 'subscription.canceled':
await db.subscription.update({
where: { polarSubscriptionId: event.data.id },
data: { status: 'canceled' },
});
break;
case 'order.created':
// One-time purchase
await db.purchase.create({
data: {
userId: event.data.metadata.userId,
polarOrderId: event.data.id,
amount: event.data.amount,
},
});
break;
}
return new Response('OK', { status: 200 });
}For Polar to send webhooks to localhost, use a tunnel like ngrok or Cloudflare Tunnel:
ngrok http 3000Then configure the webhook URL in Polar Dashboard → Webhooks → your public ngrok URL +
/api/webhooks/polar.
Any test purchase in sandbox triggers the webhook and you should see the event come in.
'use client';
import { useRouter } from 'next/navigation';
export function CheckoutButton({ productId }: { productId: string }) {
const router = useRouter();
async function handleCheckout() {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId }),
});
const { url } = await res.json();
router.push(url);
}
return <button onClick={handleCheckout}>Buy</button>;
}1. Webhook doesn't arrive: check that the public URL is reachable and the signing secret matches.
2. Invalid signature: make sure you pass the body as a raw string, not parsed, when verifying the signature.
3. User shows up but subscription doesn't: verify you pass metadata: { userId } when creating
checkout. Without it, you don't know which user the subscription belongs to.
If your volume grows a lot, Stripe becomes interesting again because of lower fees. But Polar is the fast option to start.
Integrating Polar in Next.js is ~150 lines of code if you already have auth. The webhook is the most delicate piece: spend time testing it in sandbox before going to production.
Once it works, you forget about VAT. And if you sell to Europe, that's worth its weight in gold.
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.