Architecture Philosophy

When deciding how to handle payments in this boilerplate, I intentionally moved away from complex setups that require multiple database tables for invoices, subscriptions, and customers. Instead, I opted for an MVP-first approach using Polar as the Merchant of Record.

Engine

Merchant of Record (MoR)

What is a Merchant of Record? A MoR is the legal entity responsible for selling products to your customers. This means Polar assumes all liability for collecting global taxes (like VAT), issuing invoices, and handling compliance. I don't have to write any code for international tax laws.

Efficiency

Single Column State

Rather than mirroring complex subscription objects in my database, I distill the user's access level down to a single text column: their current plan.

Polar Setup & Sandbox

Follow these steps to initialize your products and get the necessary API keys securely.

1

The Sandbox Environment

Polar provides a fully featured Sandbox environment for safe testing without using real credit cards. In the top left corner of your Polar dashboard, make sure to toggle Sandbox to the ON position while developing. You will need to create your products and webhooks specifically within this Sandbox mode before launching to production.

2

Generate Organization Access Tokens

Under Settings > Developers , generate a new Organization Access Token. Copy this immediately. Because you are in Sandbox mode, this token should start withpolar_oat_. This token allows our serverless functions to authenticate with Polar securely.

Environment Config

Your application requires these keys to be set in your .env file. They are strictly validated by Zod inside src/env.ts.

POLAR_ACCESS_TOKEN="polar_oat_..."
POLAR_WEBHOOK_SECRET="polar_wh_..."
NEXT_PUBLIC_POLAR_PRO_PRODUCT_ID="..."

Database Schema

How I handle subscription states locally in Drizzle ORM.

Instead of creating a massive, complex table structure to map every aspect of a subscription, I designed the user table to be incredibly straightforward. I added a single plancolumn that defaults to "free".

import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";

export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
plan: text("plan").default("free").notNull(),
role: text("role").default("user").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});

Webhooks Architecture

Understanding the golden rule of mapping Polar customers to local users.

What is a Webhook?

Think of a webhook as a "reverse API". Instead of my Next.js server asking Polar, "Did this user pay?", Polar actively calls my Next.js server to say, "Hey! A subscription was just created!" I use this notification to update the database immediately.

The External ID Mapping

When a user clicks a checkout link on our site, we must tell Polar who is paying. In Polar, a Customer represents a buyer. When creating a checkout session, we pass our local database user.id to Polar as the externalId.

Later, when the webhook fires, Polar tells us the customerId. We then fetch that customer from Polar, retrieve the externalId we saved earlier, and we instantly know exactly which user in our database needs to be upgraded!

Configuring the Webhook

How to expose your local machine and obtain the webhook secret.

Exposing Localhost with Ngrok

Because Polar needs to send an HTTP POST request to your application, it needs a public URL. When you are coding on localhost:3000, Polar cannot reach you. I use Ngrok to create a secure tunnel. Open two terminals and run:

pnpm dev

ngrok http 3000

Obtaining the Webhook Secret

Take the forwarding URL Ngrok gives you, append /api/webhook/polar, and paste it into your Polar Sandbox Dashboard under Settings > Webhooks . Make sure to check the events related to subscriptions (created, updated, revoked).

Once you click Create Webhook , Polar will generate a specific secret for this endpoint. It usually starts with polar_wh_. Copy this value and paste it into your .env file as thePOLAR_WEBHOOK_SECRET. This allows the application to cryptographically verify that incoming requests are genuinely from Polar.

The Webhook Handler

Polar provides a phenomenal official Next.js SDK. Instead of manually parsing headers and verifying cryptographic signatures, I use the Webhookshelper from @polar-sh/nextjs. This file lives at src/app/api/webhook/polar/route.ts.

import { Webhooks } from "@polar-sh/nextjs";
import { env } from "@/env";
import { db } from "@/db";
import { user } from "@/db/schema";
import { eq } from "drizzle-orm";
import { polar } from "@/lib/polar";

export const POST = Webhooks({
webhookSecret: env.POLAR_WEBHOOK_SECRET,

onSubscriptionCreated: async (payload) => {
const customer = await polar.customers.get({ id: payload.data.customerId });
if (!customer.externalId) return;

const isActive = payload.data.status === "active" || payload.data.status === "trialing";

await db
.update(user)
.set({ plan: isActive ? "pro" : "free" })
.where(eq(user.id, customer.externalId));
},

onSubscriptionRevoked: async (payload) => {
const customer = await polar.customers.get({ id: payload.data.customerId });
if (!customer.externalId) return;

await db
.update(user)
.set({ plan: "free" })
.where(eq(user.id, customer.externalId));
},
});

Protecting Routes

Checking if a user has paid for premium features.

Server Actions & API Routes

Because our webhook keeps the database perfectly in sync, verifying access is as fast as a single database query. I don't need to ask Polar's API every time a user loads a page.

import { auth } from "@/lib/auth";
import { db } from "@/db";

export async function performPremiumAction() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");

const userData = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, session.user.id),
columns: { plan: true },
});

if (userData?.plan !== "pro") {
throw new Error("Upgrade required");
}

return { success: true };
}

Frequently Asked Questions

Why not use Stripe directly?

Stripe is incredibly powerful, but requires you to manage tax calculation logic, receipt generation, and global compliance yourself unless you use Stripe Tax (which adds complexity). Polar handles all Merchant of Record duties automatically.

Can I have multiple paid tiers?

Yes! Just expand the plan column in the database schema to accept more string literals (e.g., "basic", "pro", "enterprise") and update the webhook logic to assign the correct string based on the Product ID returned in the payload.

Official Documentation

Dive deeper into the Polar ecosystem to learn about their full API surface and advanced checkouts.