Complete technical guide to integrating Polar into a Next.js app: SDK, checkout, webhook, local testing, and common errors.
Israel Palma
3 min read
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.
## Step 1: install the SDK
```bash
bun add @polar-sh/sdk
```
## Step 2: configure environment variables
In your `.env.local`:
```
POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=production
```
For development, use `POLAR_SERVER=sandbox`. Sandbox keys are different, configure them in a
`.env.development.local`.
## Step 3: create the client
`src/lib/polar/client.ts`:
```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',
});
```
## Step 4: create the checkout endpoint
`src/app/api/checkout/route.ts`:
```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 });
}
```
## Step 5: the webhook
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`:
```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 });
}
```
## Step 6: testing locally
For Polar to send webhooks to localhost, use a tunnel like ngrok or Cloudflare Tunnel:
```bash
ngrok http 3000
```
Then 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.
## Step 7: the checkout button on the frontend
```tsx
'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 ;
}
```
## Common errors
**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.
## When Polar beats Stripe
- You sell mostly to Europe
- Monthly volume < €5,000
- You want simplicity over advanced features
- You don't want to deal with VAT at your accountant
If your volume grows a lot, Stripe becomes interesting again because of lower fees. But Polar is the
fast option to start.
## Bottom line
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.
Enjoyed this article?
Subscribe for more tutorials and tips on building products with AI