Polar.sh Tutorial 2026: Build a SaaS with Merchant-of-Record Billing in Next.js

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

If you have ever tried to ship a global SaaS as a solo founder, you already know the trap. The product takes two weekends, but billing takes three months. Stripe handles cards, but you still have to register for VAT in fifteen jurisdictions, file quarterly sales tax in California and Texas, send 1099-Ks to the IRS, and keep a compliance lawyer on speed dial. Polar.sh flips this on its head. Instead of being a payment processor, Polar acts as the merchant of record for your sales. They are the legal seller. They collect the money, charge the right tax in every country, remit it to the right government, and pay you a clean payout in your local currency. Your only job is shipping product.

In this tutorial, you will integrate Polar.sh into a Next.js 15 SaaS from scratch. You will build a pricing page with three tiers, a hosted checkout, a customer portal, a subscription gate using middleware, webhook handlers backed by a Postgres database, usage-based metering for API calls, and a license-key flow for self-hosted plans. By the end you will have a billing layer that scales from your first customer in Tunis to your hundredth customer in Tokyo without ever filing a foreign tax return.

Prerequisites

Before starting, make sure you have:

  • Node.js 20 or newer installed
  • A Next.js 15 project using the App Router (or follow the setup step below)
  • A Polar.sh account at polar.sh (free, takes about three minutes)
  • A Postgres database (Neon, Supabase, or local Docker all work)
  • Basic familiarity with TypeScript, React Server Components, and webhooks
  • Optional: ngrok or a similar tunnel for testing webhooks locally

What You Will Build

By the end of this tutorial, you will have:

  1. A Polar.sh organization with three subscription products and a pay-as-you-go meter
  2. A Next.js 15 app with a pricing page that uses Polar Checkout for upgrades
  3. A webhook endpoint that syncs subscriptions and orders to your own database
  4. A customer portal route that lets users update payment methods and cancel
  5. Middleware that gates premium routes based on subscription status
  6. A usage-based billing flow that meters API calls and charges per unit
  7. A license-key system for customers buying a self-hosted lifetime plan

Let's start by understanding why merchant of record matters before writing a single line of code.

Why Merchant of Record Beats DIY Stripe for Most SaaS

When you take payments through Stripe directly, you are the seller. That means the legal and tax obligations land on you. In the EU you owe VAT on every B2C sale and need to file a quarterly OSS return. In the United States you owe sales tax in every state where you have economic nexus, which kicks in at as little as 100 transactions in some states. In the United Kingdom, Australia, India, and Japan there are equivalent rules. Most solo founders ignore all of this until a tax notice shows up two years later, by which point the penalties dwarf the revenue.

A merchant of record like Polar takes on that liability. They register in every jurisdiction, calculate the correct rate per cart, charge the customer, remit to the tax authority, and absorb chargebacks. You sell to Polar. Polar sells to your customer. You get a single 1099 from one US entity and a clean payout. The tradeoff is fees: Polar charges around four percent plus forty cents, compared to Stripe's roughly three percent. For most micro-SaaS that markup is cheaper than even one hour of an accountant.

Polar specifically targets developers. The API is open source, the pricing pages are component-based, and the entire product is built on Stripe under the hood, so the reliability is identical. They support subscriptions, one-off products, usage metering, license keys, and digital downloads out of the box. If you are below roughly two million dollars a year and ship a SaaS or a digital product, Polar is almost always the right call.

Step 1: Bootstrap a Next.js 15 Project

If you already have a Next.js project, skip ahead. Otherwise create a fresh one with TypeScript, Tailwind, and the App Router.

npx create-next-app@latest polar-saas \
  --typescript --tailwind --app --src-dir --import-alias "@/*"
cd polar-saas

Install the dependencies you will need across the tutorial. The Polar SDK is the official TypeScript client, Drizzle is a lightweight ORM that fits this style of webhook-driven schema, and pg is the Postgres driver.

npm install @polar-sh/sdk @polar-sh/nextjs zod
npm install drizzle-orm pg
npm install --save-dev drizzle-kit @types/pg tsx

Create a .env.local file in the root with placeholders. You will fill these in during the next step.

POLAR_ACCESS_TOKEN=
POLAR_ORGANIZATION_ID=
POLAR_WEBHOOK_SECRET=
POLAR_ENVIRONMENT=sandbox
DATABASE_URL=postgres://user:pass@localhost:5432/polar_saas
NEXT_PUBLIC_BASE_URL=http://localhost:3000

The POLAR_ENVIRONMENT flag is important. Polar runs a full sandbox at sandbox.polar.sh that mirrors production. Always develop against sandbox until your flow is solid, then flip the variable to production.

Step 2: Configure Your Polar Organization

Sign up at polar.sh and click Create Organization. Pick a slug like your-company; it shows up in your checkout URL. Switch to Developer Mode under settings, then go to Sandbox at sandbox.polar.sh and create the same organization there. You will work in sandbox for the rest of the tutorial.

Inside Sandbox, navigate to Settings, then Tokens, and create a new Personal Access Token with the products:read, products:write, subscriptions:read, orders:read, customers:read, customer_sessions:write, and webhooks:write scopes. Copy the token into POLAR_ACCESS_TOKEN. Grab the organization ID from the URL bar (the UUID after /dashboard/) and paste it into POLAR_ORGANIZATION_ID.

Now create three subscription products. Go to Products, click New, and add:

  1. Starter at nine dollars per month, recurring
  2. Pro at twenty-nine dollars per month, recurring, with a yearly variant at twenty-five percent off
  3. Lifetime at four hundred ninety-nine dollars, one-time, marked as a self-hosted license

For each product, hit the API tab inside the product page and copy the product ID. You will reference these IDs in your code rather than hardcoding prices, which keeps you flexible if you change pricing later.

Step 3: Set Up the Database Schema

Create src/db/schema.ts. The goal is to mirror the parts of Polar that your app cares about: customers, subscriptions, and a usage table for metering. You do not need to mirror products, since those are read directly from Polar via the SDK.

import { pgTable, text, timestamp, integer, jsonb } from "drizzle-orm/pg-core";
 
export const customers = pgTable("customers", {
  id: text("id").primaryKey(),
  polarCustomerId: text("polar_customer_id").notNull().unique(),
  email: text("email").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});
 
export const subscriptions = pgTable("subscriptions", {
  id: text("id").primaryKey(),
  customerId: text("customer_id").references(() => customers.id).notNull(),
  productId: text("product_id").notNull(),
  status: text("status").notNull(),
  currentPeriodEnd: timestamp("current_period_end").notNull(),
  metadata: jsonb("metadata"),
});
 
export const usageEvents = pgTable("usage_events", {
  id: text("id").primaryKey(),
  customerId: text("customer_id").notNull(),
  meterId: text("meter_id").notNull(),
  units: integer("units").notNull(),
  ingestedAt: timestamp("ingested_at").defaultNow().notNull(),
});
 
export const licenseKeys = pgTable("license_keys", {
  id: text("id").primaryKey(),
  key: text("key").notNull().unique(),
  customerId: text("customer_id").notNull(),
  productId: text("product_id").notNull(),
  activations: integer("activations").default(0).notNull(),
  maxActivations: integer("max_activations").default(3).notNull(),
});

Wire up the Drizzle client in src/db/index.ts so server components and route handlers can import a single instance.

import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
 
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });

Add a Drizzle config at the project root and run the initial migration.

npx drizzle-kit push --dialect postgresql --schema ./src/db/schema.ts

Step 4: Build the Pricing Page

Pricing pages are the single biggest leak in most SaaS funnels, so it is worth doing this properly. The pattern below fetches products live from Polar at request time, which means a price change in the dashboard ships instantly without a redeploy.

Create src/lib/polar.ts to centralize the Polar client.

import { Polar } from "@polar-sh/sdk";
 
export const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  server: process.env.POLAR_ENVIRONMENT === "production" ? "production" : "sandbox",
});

Now build the pricing page as a Server Component at src/app/pricing/page.tsx. The product listing endpoint paginates, so always pass an organization ID and limit to keep the response fast.

import { polar } from "@/lib/polar";
import { CheckoutButton } from "./checkout-button";
 
export default async function PricingPage() {
  const { result } = await polar.products.list({
    organizationId: process.env.POLAR_ORGANIZATION_ID!,
    isArchived: false,
    limit: 20,
  });
 
  return (
    <main className="mx-auto max-w-5xl px-6 py-16">
      <h1 className="text-4xl font-semibold mb-12 text-center">Pricing</h1>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {result.items.map((product) => (
          <div key={product.id} className="rounded-2xl border p-6">
            <h2 className="text-xl font-semibold">{product.name}</h2>
            <p className="text-sm text-gray-600 mt-2">{product.description}</p>
            <p className="text-3xl font-bold mt-4">
              ${(product.prices[0].priceAmount ?? 0) / 100}
              <span className="text-base text-gray-500">
                {product.prices[0].type === "recurring" ? "/mo" : ""}
              </span>
            </p>
            <CheckoutButton productId={product.id} />
          </div>
        ))}
      </div>
    </main>
  );
}

The checkout button is a Client Component that opens Polar's hosted checkout in an embedded iframe. This is the single biggest UX win compared to redirecting away from your domain, because abandonment rates drop by roughly twenty percent when users stay on the page.

"use client";
 
import { useState } from "react";
 
export function CheckoutButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
 
  async function handleClick() {
    setLoading(true);
    const res = await fetch("/api/checkout", {
      method: "POST",
      body: JSON.stringify({ productId }),
    });
    const { url } = await res.json();
    window.location.href = url;
  }
 
  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="mt-6 w-full rounded-lg bg-black text-white py-2 font-medium"
    >
      {loading ? "Loading..." : "Subscribe"}
    </button>
  );
}

Step 5: Create the Checkout Route Handler

The checkout route creates a Polar Checkout session and returns the hosted URL. Pass customerExternalId so that when the webhook fires later you can match the Polar customer back to your own user record without an extra lookup.

Create src/app/api/checkout/route.ts.

import { polar } from "@/lib/polar";
import { NextRequest, NextResponse } from "next/server";
 
export async function POST(req: NextRequest) {
  const { productId } = await req.json();
 
  const checkout = await polar.checkouts.create({
    products: [productId],
    successUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?checkout_id={CHECKOUT_ID}`,
    customerExternalId: "user_abc123",
    metadata: { source: "pricing_page" },
  });
 
  return NextResponse.json({ url: checkout.url });
}

In production, replace the hardcoded customerExternalId with the authenticated user ID from your auth provider, whether that is Clerk, Better Auth, or NextAuth. The {CHECKOUT_ID} template variable is replaced by Polar after payment, which lets the success page confirm the order without trusting a query string the user controls.

Step 6: Handle Webhooks for Subscription Sync

Webhooks are the only reliable way to keep your database in sync with Polar. Polling is fragile and racy. The Polar SDK ships an Express-style helper that verifies signatures using HMAC, so you do not have to reimplement that yourself.

Create src/app/api/webhooks/polar/route.ts.

import { Webhooks } from "@polar-sh/nextjs";
import { db } from "@/db";
import { customers, subscriptions } from "@/db/schema";
 
export const POST = Webhooks({
  webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
 
  onSubscriptionCreated: async (payload) => {
    const sub = payload.data;
    await db.insert(customers).values({
      id: sub.customer.externalId ?? sub.customer.id,
      polarCustomerId: sub.customer.id,
      email: sub.customer.email,
    }).onConflictDoNothing();
 
    await db.insert(subscriptions).values({
      id: sub.id,
      customerId: sub.customer.externalId ?? sub.customer.id,
      productId: sub.productId,
      status: sub.status,
      currentPeriodEnd: new Date(sub.currentPeriodEnd!),
    });
  },
 
  onSubscriptionUpdated: async (payload) => {
    const sub = payload.data;
    await db.update(subscriptions)
      .set({
        status: sub.status,
        currentPeriodEnd: new Date(sub.currentPeriodEnd!),
      })
      .where((s, { eq }) => eq(s.id, sub.id));
  },
 
  onSubscriptionCanceled: async (payload) => {
    await db.update(subscriptions)
      .set({ status: "canceled" })
      .where((s, { eq }) => eq(s.id, payload.data.id));
  },
 
  onOrderCreated: async (payload) => {
    console.log("One-time order received:", payload.data.id);
  },
});

To register this URL with Polar, go to Settings, Webhooks, and add https://your-domain.com/api/webhooks/polar with all subscription and order events selected. Copy the signing secret into POLAR_WEBHOOK_SECRET. For local development, run ngrok and point it at port 3000, then register the ngrok URL temporarily.

ngrok http 3000

Polar retries failed webhook deliveries with exponential backoff for up to twenty-four hours, but you should still treat each handler as idempotent. The onConflictDoNothing clause and the update pattern above are both safe to run twice.

Step 7: Gate Premium Routes With Middleware

A subscription database is useless if you do not actually check it. Next.js middleware runs before every request, which makes it the right place to enforce paywalls without scattering checks across pages. Create src/middleware.ts.

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { subscriptions } from "@/db/schema";
import { eq, and, gte } from "drizzle-orm";
 
const PROTECTED_PATHS = ["/dashboard/pro", "/api/pro"];
 
export async function middleware(req: NextRequest) {
  if (!PROTECTED_PATHS.some((p) => req.nextUrl.pathname.startsWith(p))) {
    return NextResponse.next();
  }
 
  const userId = req.cookies.get("user_id")?.value;
  if (!userId) return NextResponse.redirect(new URL("/login", req.url));
 
  const active = await db.select().from(subscriptions).where(
    and(
      eq(subscriptions.customerId, userId),
      eq(subscriptions.status, "active"),
      gte(subscriptions.currentPeriodEnd, new Date()),
    )
  );
 
  if (active.length === 0) {
    return NextResponse.redirect(new URL("/pricing", req.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/pro/:path*", "/api/pro/:path*"],
};

Note that middleware runs on the Edge runtime by default, which means you cannot use the standard pg driver. For Edge compatibility, swap the Drizzle pool for the Neon HTTP driver or push the auth check to a Route Handler. The simplest fix during development is to add export const runtime = "nodejs" if you are running middleware on Node 20.

Step 8: Embed the Customer Portal

When a customer wants to update their card or cancel, you do not have to build any of that UI. Polar ships a hosted customer portal that handles payment methods, invoices, plan changes, and cancellations. Generate a one-time signed URL and redirect.

Create src/app/api/portal/route.ts.

import { polar } from "@/lib/polar";
import { NextRequest, NextResponse } from "next/server";
 
export async function POST(req: NextRequest) {
  const { customerId } = await req.json();
 
  const session = await polar.customerSessions.create({
    customerExternalId: customerId,
  });
 
  return NextResponse.json({ url: session.customerPortalUrl });
}

The session URL is single-use and expires in one hour. Always generate it on demand server-side rather than caching it. On the client, a single button is enough.

"use client";
export function PortalButton({ customerId }: { customerId: string }) {
  return (
    <button onClick={async () => {
      const res = await fetch("/api/portal", {
        method: "POST",
        body: JSON.stringify({ customerId }),
      });
      const { url } = await res.json();
      window.open(url, "_blank");
    }}>
      Manage subscription
    </button>
  );
}

Step 9: Add Usage-Based Billing With Meters

For AI products and APIs, flat subscriptions leave money on the table. Polar Meters let you charge per unit, with a free tier and an overage rate, all settled in the next invoice. In Sandbox, go to Meters, click New Meter, name it api_calls, and set the aggregation to sum of units. Attach the meter to your Pro product as a metered price at one cent per call after the first ten thousand free calls per month.

Now ingest events from your API. Create src/lib/meter.ts.

import { polar } from "@/lib/polar";
 
export async function recordUsage(
  customerExternalId: string,
  units: number,
) {
  await polar.events.ingest({
    events: [
      {
        name: "api_calls",
        externalCustomerId: customerExternalId,
        metadata: { units },
      },
    ],
  });
}

Call recordUsage from any route that consumes a billable resource. Polar deduplicates by event ID for one hour, so retries from a flaky client will not double-charge. The next invoice automatically includes the metered overage line item; you do not have to compute or invoice anything yourself.

Step 10: Issue License Keys for Lifetime Plans

Some buyers want a one-time payment with a self-hosted binary or library. Polar issues license keys natively. When you create the Lifetime product, enable License Keys and set the activation limit to three machines per key.

In your webhook handler, listen for onOrderCreated and persist the issued key.

onOrderCreated: async (payload) => {
  const order = payload.data;
  const benefit = order.items[0]?.benefits.find((b) => b.type === "license_keys");
  if (!benefit?.licenseKey) return;
 
  await db.insert(licenseKeys).values({
    id: benefit.licenseKey.id,
    key: benefit.licenseKey.key,
    customerId: order.customer.externalId ?? order.customer.id,
    productId: order.productId,
    maxActivations: 3,
  });
},

Then expose a validation endpoint your CLI or self-hosted app can hit. Polar handles the heavy lifting via licenseKeys.validate.

import { polar } from "@/lib/polar";
 
export async function POST(req: NextRequest) {
  const { key, instanceId } = await req.json();
  try {
    const result = await polar.licenseKeys.validate({
      key,
      activationId: instanceId,
    });
    return NextResponse.json({ valid: true, expiresAt: result.expiresAt });
  } catch {
    return NextResponse.json({ valid: false }, { status: 403 });
  }
}

Testing Your Implementation

Run the dev server and walk through the full flow end to end.

npm run dev

Open /pricing and click Subscribe on the Starter tier. Use the Polar sandbox test card 4242 4242 4242 4242 with any future expiry and CVC. Complete checkout. You should be redirected back to /dashboard, and your terminal should show the webhook hitting onSubscriptionCreated. Query your local Postgres to confirm a row landed in both customers and subscriptions.

Now hit /dashboard/pro directly. The middleware should let you through because your subscription is active. Open the Polar sandbox dashboard, find the customer, and cancel the subscription. Within seconds, your local database row updates to canceled, and the next request to /dashboard/pro redirects to pricing.

For metering, run a script that calls recordUsage ten thousand and one times. Check the Polar dashboard, view the customer, and verify the meter shows one cent in pending charges. The next invoice will include this line item automatically.

Troubleshooting

Webhook signature verification fails. Double-check that you copied the signing secret from the same environment you are sending from. Sandbox and production have different secrets, and mixing them is the most common cause.

Checkout returns a 422. Polar requires that the product has at least one published price. If you create a product but never click Publish, the checkout endpoint refuses it. Confirm the product is marked Active in the dashboard.

Customer email is missing in the webhook. This happens when a checkout completes without an email collected, usually because of a custom integration that bypassed the form. Always set customerEmail on the checkout creation if you have it from your own auth system.

Middleware times out at the edge. Database queries from middleware should use HTTP-based drivers like Neon or Turso. Connection-pool drivers like pg only work in the Node runtime, which middleware does not use by default.

Next Steps

  • Wire Polar with Better Auth using the Polar Better Auth plugin to skip building your own user-customer mapping
  • Add a referral program using Polar's affiliate features for recurring commissions
  • Self-host a community storefront using Polar's open-source Next.js starter
  • Move from sandbox to production by flipping POLAR_ENVIRONMENT and registering the live webhook URL
  • Read our Stripe integration guide to compare with the DIY approach if your revenue scales past two million

Conclusion

You now have a complete billing layer for a global SaaS without ever touching a tax form. Polar.sh handles the legal and compliance overhead while exposing a developer-friendly SDK that fits naturally into Next.js 15 patterns: Server Components for the pricing page, Route Handlers for checkout and webhooks, and middleware for paywalls. The same pattern scales from a side project at one customer to a real business at ten thousand. The only thing left to build is the product itself.


Want to read more tutorials? Check out our latest tutorial on An Introduction to GPT-4o and GPT-4o mini.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles