Next.js · Better Auth · Polar ·

Better Auth + Polar in Next.js with Drizzle (Guide)

If you'd rather have your AI agent follow these patterns for you, the Stack Pack has Cursor rules, skills, and Claude Code hooks for exactly this stack: $15 (plus VAT where applicable).

This guide pairs with Stack Pack, and if you want the next step, read Supabase getSession vs getUser vs getClaims: which to use where.

This guide wires the Better Auth Polar plugin (@polar-sh/better-auth) into a Next.js 16 App Router app that uses Drizzle on Postgres. By the end you'll have sign-up that creates a Polar customer, a checkout button, a customer portal, verified webhooks that write access into your own table, and paid routes that check that table on the server.

What we checked. Every API below was checked against the Better Auth and Polar docs on 23 Oct 2026 (links at the end). The server code type-checks with TypeScript 7.0.2 against better-auth 1.7.12, @better-auth/drizzle-adapter 1.7.12, @polar-sh/better-auth 2.1.0, @polar-sh/sdk 1.0.2, drizzle-orm 0.45.4, drizzle-kit 0.31.11, and next 16.4.2, and drizzle-kit generate produced a migration from the schema. We didn't run it against a live Polar account or a hosted database. Versions move quickly, so check the docs before you copy.

What you're building (auth, checkout, portal, webhooks, gated routes)

  • Auth: Better Auth with email and password, stored in Postgres through the Drizzle adapter.
  • Checkout: authClient.checkout({ slug: "pro" }) sends the signed-in user to Polar checkout.
  • Portal: authClient.customer.portal() lets them manage billing.
  • Webhooks: Polar tells your server when a customer's state changes, and you write that into an entitlement table.
  • Gating: paid pages and route handlers read entitlement on the server, never client state.

Polar's Better Auth adapter page says the plugin needs Better Auth 1.7 or later and Node.js 22 or later. As of 23 Oct 2026, @polar-sh/better-auth 2.1.0 itself declares Node 24 or later in its package metadata, so plan for Node 24.

Install and configure Better Auth with the Drizzle adapter

npm install better-auth @better-auth/drizzle-adapter @polar-sh/better-auth @polar-sh/sdk zod drizzle-orm postgres
npm install -D drizzle-kit

The Drizzle adapter now ships as its own package, @better-auth/drizzle-adapter. Better Auth 1.7.12 also still exports better-auth/adapters/drizzle; we use the package the current docs show. Polar's install command also includes zod, so we install it here too.

Your Drizzle client:

// db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

Generating the Better Auth Drizzle schema

Better Auth's CLI reads your auth config (next section) and writes the tables it needs (user, session, account, verification) as a Drizzle schema:

npx auth@latest generate --config lib/auth.ts --output db/auth-schema.ts

Run it again whenever you add a Better Auth plugin that needs its own tables. Next to it, add the table that records who has paid:

// db/entitlements.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { user } from "./auth-schema";

export const entitlement = pgTable("entitlement", {
  userId: text("user_id")
    .primaryKey()
    .references(() => user.id, { onDelete: "cascade" }),
  plan: text("plan").notNull(), // "free" | "pro"
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

// db/schema.ts
export * from "./auth-schema";
export * from "./entitlements";

Running migrations

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./db/schema.ts",
  out: "./drizzle",
  dbCredentials: { url: process.env.DATABASE_URL! },
});
npx drizzle-kit generate   # writes a SQL migration to ./drizzle
npx drizzle-kit migrate    # applies it

Commit the migration files. drizzle-kit push skips the migration file, which is fine for a throwaway local database but makes production changes hard to review.

Add the Polar plugin

Create an Organization Access Token in your Polar organization settings and add POLAR_ACCESS_TOKEN to .env.local, plus POLAR_WEBHOOK_SECRET and your Pro product's ID. Sandbox and production are separate: tokens and products from one don't work in the other.

// lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { polar, checkout, portal, webhooks } from "@polar-sh/better-auth";
import { createPolarCore } from "@polar-sh/sdk/2026-10";
import { db } from "@/db";
import * as schema from "@/db/schema";
import { syncEntitlement } from "@/lib/entitlements";

const polarClient = createPolarCore({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  // "sandbox" while testing; tokens and products don't carry across environments
  environment: process.env.POLAR_ENV === "production" ? "production" : "sandbox",
});

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg", schema }),
  emailAndPassword: { enabled: true },
  plugins: [
    polar({
      client: polarClient,
      createCustomerOnSignUp: true, // Polar customer external_id = your user id
      use: [
        checkout({
          products: [{ productId: process.env.POLAR_PRO_PRODUCT_ID!, slug: "pro" }],
          successUrl: "/dashboard?checkout_id={CHECKOUT_ID}",
          authenticatedUsersOnly: true,
        }),
        portal(),
        webhooks({
          secret: process.env.POLAR_WEBHOOK_SECRET!,
          onCustomerStateChanged: async (payload) => {
            await syncEntitlement(payload.data);
          },
        }),
      ],
    }),
    nextCookies(), // keep this last
  ],
});

Three details here are easy to get wrong:

  • The Polar client. Polar's docs create it with createPolarCore from @polar-sh/sdk/2026-10. Some older examples (including, on 23 Oct 2026, the Polar page on better-auth.com) still use new Polar() from @polar-sh/sdk, which the 1.0.2 SDK no longer exports from its root.
  • createCustomerOnSignUp: true creates a Polar customer whose external ID is your Better Auth user ID, so you don't need a mapping table.
  • nextCookies() goes last in the plugins array, so Server Actions that sign users in can set cookies.

Mount the handler:

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

Checkout

Add the client plugin, then call checkout from a button in a client component:

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { polarClient } from "@polar-sh/better-auth/client";

export const authClient = createAuthClient({
  plugins: [polarClient()],
});

// In a client component:
export const upgrade = () => authClient.checkout({ slug: "pro" });
export const manageBilling = () => authClient.customer.portal();

With authenticatedUsersOnly: true, signed-out users can't start a checkout, and the signed-in user is attached as the customer. The {CHECKOUT_ID} placeholder in successUrl is filled in by Polar. Don't grant access on the success page: anyone can visit that URL. Access comes from the webhook.

Type-check note: with TypeScript 5.9.3 and 6.0.3, the polarClient() line in this file reported a type error against these package versions; TypeScript 7.0.2 passed with no errors. The server files passed on all three.

Customer portal

portal() on the server adds authClient.customer.portal(), which redirects to Polar's customer portal for orders, subscriptions, and benefits. It also adds authClient.customer.state() and list methods for benefits, orders, and subscriptions. Those are handy for showing billing details in your UI, but for access decisions use your own table, filled from webhooks.

Usage and benefits (brief)

Polar's usage() plugin adds event ingestion and customer meters for usage-based billing. Polar's docs recommend ingesting billable events from your server, with external_customer_id: session.user.id, rather than from the browser, because the client endpoint trusts whatever the client sends. If you only sell a plan, you can leave usage() out.

Polar webhooks: verify, then sync state into your DB

With the webhooks() plugin in the config above, Better Auth serves the endpoint at /api/auth/polar/webhooks (the default base path plus /polar/webhooks). Add that URL as a webhook endpoint in your Polar organization settings and copy its secret into POLAR_WEBHOOK_SECRET.

Per Polar's docs, the plugin checks the webhook-id, webhook-timestamp, and webhook-signature headers before calling any handler. A missing or invalid signature returns 403, a malformed payload returns 400, and if your handler throws, the request fails so Polar retries. Payloads use the SDK's snake_case field names.

The onCustomerStateChanged handler receives the customer's full state, including external_id and active_subscriptions, so you can write the whole answer each time:

// lib/entitlements.ts
import { db } from "@/db";
import { entitlement } from "@/db/schema";

const PRO_PRODUCT_ID = process.env.POLAR_PRO_PRODUCT_ID!;

type CustomerState = {
  external_id?: string | null;
  active_subscriptions: { product_id: string }[];
};

export async function syncEntitlement(state: CustomerState) {
  const userId = state.external_id;
  if (!userId) return; // not one of our users

  const plan = state.active_subscriptions.some((s) => s.product_id === PRO_PRODUCT_ID)
    ? "pro"
    : "free";

  await db
    .insert(entitlement)
    .values({ userId, plan })
    .onConflictDoUpdate({
      target: entitlement.userId,
      set: { plan, updatedAt: new Date() },
    });
}

Because it's an upsert of the current state, receiving the same event twice is designed to leave the same row behind.

Prefer a separate route? You can skip the webhooks() plugin and verify in your own route handler with the SDK's validateEvent. Read the raw body with request.text() and verify before you parse anything:

// app/api/webhooks/polar/route.ts
import { webhooks } from "@polar-sh/sdk/2026-10";

export async function POST(request: Request) {
  const body = await request.text(); // raw body: verify before parsing
  let event;
  try {
    event = await webhooks.validateEvent(
      body,
      {
        "webhook-id": request.headers.get("webhook-id") ?? "",
        "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
        "webhook-signature": request.headers.get("webhook-signature") ?? "",
      },
      process.env.POLAR_WEBHOOK_SECRET!,
    );
  } catch (error) {
    if (error instanceof webhooks.PolarWebhookVerificationError) {
      return new Response(null, { status: 403 });
    }
    if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
      return new Response(null, { status: 202 }); // newer event type: acknowledge
    }
    throw error;
  }

  // dedupe on the webhook-id header, then update your tables
  console.log(event.type);
  return new Response(null, { status: 202 });
}

Polar retries failed deliveries and disables an endpoint after 10 consecutive failures, and it treats redirects as failures. So make sure the webhook path isn't caught by an auth redirect, and return quickly. For the Stripe version of the same problem, see why Stripe webhook signature verification fails in Next.js.

Gate paid routes in Next.js (server components, route handlers, middleware)

Check the session and the entitlement on the server, in one helper:

// lib/require-pro.ts
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { entitlement } from "@/db/schema";

export async function requirePro() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect("/sign-in");

  const row = await db.query.entitlement.findFirst({
    where: eq(entitlement.userId, session.user.id),
  });
  if (row?.plan !== "pro") redirect("/pricing");

  return session;
}

In a server component:

// app/dashboard/page.tsx
import { requirePro } from "@/lib/require-pro";

export default async function DashboardPage() {
  const session = await requirePro();
  return <h1>Welcome, {session.user.name}</h1>;
}

In a route handler, return a status instead of redirecting:

// app/api/export/route.ts
import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { entitlement } from "@/db/schema";

export async function GET() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return new Response(null, { status: 401 });

  const row = await db.query.entitlement.findFirst({
    where: eq(entitlement.userId, session.user.id),
  });
  if (row?.plan !== "pro") return new Response(null, { status: 403 });

  return Response.json({ ok: true });
}

In Next.js 16, middleware is now proxy.ts. Better Auth's docs recommend using it only for an optimistic redirect based on the session cookie, and doing the real check in each page or route:

// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function proxy(request: NextRequest) {
  // Optimistic redirect only. The real check is requirePro() on the server.
  if (!getSessionCookie(request)) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"], // never match /api/auth/polar/webhooks
};

getSessionCookie only checks that a cookie exists. It doesn't validate it, so it's never the access check on its own.

Common mistakes AI agents make with this stack

  • Trusting client state. Granting access because the success page loaded, or because authClient.customer.state() said so in the browser. Gate on your server-side entitlement row.
  • Skipping webhook verification. Parsing request.json() first, or "temporarily" disabling the signature check to make a test pass. Verify the raw body, then parse.
  • Leaking secrets to the client. Importing lib/auth.ts or the database into a client component, or renaming POLAR_ACCESS_TOKEN to NEXT_PUBLIC_ to "fix" an undefined value.
  • Stale APIs. new Polar() from the SDK root, or middleware.ts examples copied into a Next.js 16 app.
  • Pushing schema to production. Running drizzle-kit push against a shared database instead of committing a migration.

For more on this pattern, see our security-focused Cursor rules for Next.js, Supabase, and Stripe.

Using a different stack? Ship-Ready AI Rules covers Next.js, Supabase, and Stripe, and Guardrails focuses on keeping coding agents away from destructive commands.

FAQ

Does Better Auth support Polar?

Yes, through the @polar-sh/better-auth plugin, which Polar maintains. It adds checkout, a customer portal, usage billing, and webhook handling to Better Auth.

How do I handle Polar webhooks with Better Auth?

Add the webhooks() plugin with your POLAR_WEBHOOK_SECRET and point Polar at /api/auth/polar/webhooks. The plugin verifies signatures before your handlers run. Or verify in your own route with validateEvent from @polar-sh/sdk/2026-10.

Which Drizzle schema does Better Auth need?

The core tables are user, session, account, and verification. Let npx auth@latest generate write them from your config instead of typing them by hand, then create a migration with drizzle-kit generate.

Can I use Polar with Next.js App Router?

Yes. Mount Better Auth with toNextJsHandler in app/api/auth/[...all]/route.ts. Polar also publishes a separate @polar-sh/nextjs adapter if you aren't using Better Auth.

Polar or Stripe for an indie SaaS?

It depends on your situation. Polar describes itself as a merchant of record for developers. Compare how each one handles sales tax, current fees, and supported countries on both sites before you choose.

Sources

Checked 23 Oct 2026: better-auth.com/docs/plugins/polar, better-auth.com/docs/adapters/drizzle, better-auth.com/docs/integrations/next, polar.sh/docs/integrate/sdk/adapters/better-auth, polar.sh/docs/integrate/webhooks/delivery. Package versions from the npm registry on the same day.

Cursor is a trademark of Anysphere, Inc. Claude and Claude Code are trademarks of Anthropic. Next.js and Vercel are trademarks of Vercel, Inc. Codex is a trademark of OpenAI. GitHub Copilot is a trademark of GitHub, Inc. Postgres and PostgreSQL are registered trademarks of the PostgreSQL Community Association of Canada. Better Auth, Polar, Drizzle, Stripe, Supabase, and other product names belong to their respective owners. They are used here only to identify those products. We aren't affiliated with, sponsored by, or endorsed by any of them.