Integrating Polar Payments with BetterAuth in Next.js
Integrating Polar Payments with BetterAuth in Next.js
Integrating Polar Payments with BetterAuth in Next.js: The Complete Guide
If you're building a SaaS application with Next.js and want to add payment processing, combining Polar and BetterAuth is one of the most seamless approaches available. However, getting them to work together correctly requires understanding how Next.js handles server and client code—something that trips up many developers.
In this article, I'll walk you through exactly how to set up Polar payments with BetterAuth in Next.js, explain the architecture behind the scenes, and show you the most common pitfalls (and how to avoid them).
The Problem: Two Runtimes, One Application
When you build a Next.js application, you're actually working in two completely different JavaScript environments:
The Server (Node.js)
The Browser (Client)
Most developers naturally think of their Next.js app as a single thing. But when it comes to integrating Polar payments with BetterAuth, you need to think of these as two separate systems that communicate through HTTP requests.
The issue is: Polar and BetterAuth have server-side components that use Node.js-only modules. If you accidentally try to use these on the client side, you'll get errors like:
Module not found: node:module
the chunking context does not support external modules
These errors happen because you're trying to run server code in the browser, which is impossible.
Understanding the Architecture
Let me show you how data flows through your application when a user checks out:
1User clicks "Checkout" button
2 ↓
3React component calls authClient.checkout()
4 ↓
5Browser sends HTTP request to your server
6 ↓
7BetterAuth server receives request
8 ↓
9Polar plugin (server-only) is triggered
10 ↓
11Polar SDK communicates with Polar API
12 ↓
13Checkout URL is generated
14 ↓
15Server responds to browser with checkout URL
16 ↓
17Browser redirects user to Polar checkout page
18 ↓
19User completes payment on Polar
20 ↓
21Polar redirects user back to your successUrl
22Here's a visual representation:
1┌─────────────────────────────────────────────────────────────┐
2│ YOUR NEXT.JS APPLICATION │
3└─────────────────────────────────────────────────────────────┘
4 │
5 ┌─────────┴─────────┐
6 │ │
7 ┌───────▼──────────┐ ┌────▼────────────┐
8 │ CLIENT LAYER │ │ SERVER LAYER │
9 │ (Browser) │ │ (Node.js) │
10 │ │ │ │
11 │ React Components │ │ auth.ts (your │
12 │ authClient │ │ BetterAuth │
13 │ (No Polar code) │ │ config with │
14 │ │ │ Polar plugins) │
15 └───────┬──────────┘ └────┬────────────┘
16 │ │
17 │ HTTP Request │
18 │ (POST /api/...) │
19 └──────────┬────────┘
20 │
21 ┌──────────▼──────────────┐
22 │ BetterAuth API │
23 │ (Internal Endpoints) │
24 │ - /api/auth/checkout │
25 │ - /api/auth/customer │
26 │ - /api/auth/usage │
27 └──────────┬──────────────┘
28 │
29 ┌──────────▼──────────────┐
30 │ Polar Plugin │ ← ALL SERVER-SIDE
31 │ (Executes here only) │ - checkout()
32 │ - checkout │ - portal()
33 │ - portal │ - usage.ingest()
34 │ - usage │ - webhooks handling
35 │ - webhooks │
36 └──────────┬──────────────┘
37 │
38 ┌──────────▼──────────────┐
39 │ Polar SDK │
40 │ (Payment Provider API) │
41 └─────────────────────────┘
42The key insight: All the Polar logic lives on your server. The client just makes simple HTTP calls.
Setting Up Polar + BetterAuth: Step by Step
Now let's actually build this. I'll walk you through each step.
Step 1: Install the Dependencies
First, add the necessary packages to your project:
npm install better-auth @polar-sh/better-auth @polar-sh/sdk
Or if you're using pnpm:
pnpm add better-auth @polar-sh/better-auth @polar-sh/sdk
Step 2: Configure Your Environment Variables
Create a .env.local file in your project root:
# .env.local
POLAR_ACCESS_TOKEN=your_polar_organization_token
POLAR_WEBHOOK_SECRET=your_polar_webhook_secret
BETTER_AUTH_URL=http://localhost:3000
Important: The POLAR_ACCESS_TOKEN is sensitive. Never expose it to the browser. This will stay on your server only.
To get your token, log into your Polar dashboard, go to Organization Settings, and create an Organization Access Token.
Step 3: Configure Your Server (auth.ts)
This is the most important file. This is where ALL Polar integration happens. Create a file called src/auth.ts:
1// src/auth.ts (Server-only file)
2import { betterAuth } from "better-auth";
3import { polar, checkout, portal, usage, webhooks } from "@polar-sh/better-auth";
4import { Polar } from "@polar-sh/sdk";
5import { drizzleAdapter } from "better-auth/adapters/drizzle";
6import { db } from "@/lib/db";
7import * as schema from "@/drizzle/schema";
8
9// Initialize Polar SDK with your server-side token
10const polarClient = new Polar({
11 accessToken: process.env.POLAR_ACCESS_TOKEN!,
12 // Use 'sandbox' for testing, remove for production
13 server: process.env.NODE_ENV === "production" ? undefined : "sandbox",
14});
15
16export const auth = betterAuth({
17 appName: "Your App Name",
18 baseURL: process.env.BETTER_AUTH_URL,
19
20 database: drizzleAdapter(db, {
21 provider: "pg",
22 schema,
23 }),
24
25 plugins: [
26 polar({
27 client: polarClient, // ✅ Polar SDK instance (server-only)
28 createCustomerOnSignUp: true, // Auto-create Polar customer on signup
29 getCustomerCreateParams: ({ user }) => ({
30 metadata: {
31 userId: user.id,
32 email: user.email,
33 signupDate: new Date().toISOString(),
34 },
35 }),
36 use: [
37 // Checkout plugin - enables payment processing
38 checkout({
39 products: [
40 {
41 productId: "product-id-from-polar", // Get from Polar Dashboard
42 slug: "Monthly", // Use this slug in your frontend
43 },
44 {
45 productId: "yearly-product-id",
46 slug: "Yearly",
47 },
48 ],
49 successUrl: "/dashboard/billing/success?checkout_id={CHECKOUT_ID}",
50 returnUrl: "/dashboard/billing",
51 authenticatedUsersOnly: true,
52 theme: "light",
53 }),
54
55 // Portal plugin - customer management
56 portal({
57 returnUrl: "/dashboard/billing",
58 }),
59
60 // Usage plugin - metering and usage-based billing
61 usage(),
62
63 // Webhooks plugin - handle Polar events
64 webhooks({
65 secret: process.env.POLAR_WEBHOOK_SECRET!,
66 onOrderPaid: (payload) => {
67 console.log("Order paid:", payload);
68 // Update user subscription status, send email, etc.
69 },
70 onSubscriptionActive: (payload) => {
71 console.log("Subscription activated:", payload);
72 // Grant access to premium features
73 },
74 onSubscriptionCanceled: (payload) => {
75 console.log("Subscription canceled:", payload);
76 // Revoke access to premium features
77 },
78 onCustomerStateChanged: (payload) => {
79 console.log("Customer state changed:", payload);
80 },
81 onPayload: (payload) => {
82 console.log("Webhook received:", payload.type);
83 },
84 }),
85 ],
86 }),
87 ],
88});
89This file does several important things:
Step 4: Create Your Client Configuration
Now create src/auth-client.ts. This is intentionally minimal:
1// src/auth-client.ts
2"use client"; // ✅ This is a client component
3
4import { createAuthClient } from "better-auth/react";
5
6export const authClient = createAuthClient({
7 baseURL: process.env.NODE_ENV === "development"
8 ? "http://localhost:3000"
9 : "https://yourdomain.com",
10});
11
12// Export only the methods you need
13export const {
14 signIn,
15 signUp,
16 signOut,
17 useSession,
18 organization,
19 // ❌ Do NOT import or use polarClient()
20} = authClient;
21Notice what's NOT here:
This is intentional. The Polar plugin is already configured on your server, and BetterAuth automatically exposes its functionality through the client.
Why not use polarClient()?
Some older documentation shows importing polarClient() from @polar-sh/better-auth and adding it to the client. Don't do this. It adds server-only code to your client bundle, which causes the module mismatch errors you're trying to avoid. The methods are already available on authClient automatically—you don't need to configure anything extra on the client.
Using Polar in Your React Components
Now that everything is configured, using Polar in your components is straightforward. Let me show you some practical examples.
Example 1: Implementing a Checkout Button
Here's a real-world checkout button component:
1// src/components/CheckoutButton.tsx
2"use client";
3
4import { authClient, useSession } from "@/auth-client";
5import { Button } from "@/components/ui/button";
6import { useRouter } from "next/navigation";
7import { useState } from "react";
8import { toast } from "sonner";
9
10export function CheckoutButton({ planSlug }: { planSlug: string }) {
11 const [isLoading, setIsLoading] = useState(false);
12 const { data: session } = useSession();
13 const router = useRouter();
14
15 const handleCheckout = async () => {
16 try {
17 setIsLoading(true);
18
19 // If user not logged in, redirect to sign up
20 if (!session?.user) {
21 router.push(`/sign-up?redirect=/pricing&plan=${planSlug}`);
22 return;
23 }
24
25 // ✅ Call checkout - this communicates with your server
26 await authClient.checkout({
27 slug: planSlug, // Must match slug in your auth.ts config
28 });
29
30 // User is automatically redirected to Polar checkout
31 } catch (error) {
32 console.error("Checkout failed:", error);
33 toast.error("Failed to start checkout. Please try again.");
34 } finally {
35 setIsLoading(false);
36 }
37 };
38
39 return (
40 <Button onClick={handleCheckout} disabled={isLoading}>
41 {isLoading ? "Processing..." : "Start Free Trial"}
42 </Button>
43 );
44}
45When a user clicks this button:
authClient.checkout({ slug: "Monthly" })Example 2: Opening the Billing Portal
Users need a way to manage their subscriptions and invoices. Use the billing portal:
1// src/components/BillingPortal.tsx
2"use client";
3
4import { authClient } from "@/auth-client";
5import { Button } from "@/components/ui/button";
6import { useState } from "react";
7import { toast } from "sonner";
8
9export function BillingPortal() {
10 const [isLoading, setIsLoading] = useState(false);
11
12 const handleOpenPortal = async () => {
13 try {
14 setIsLoading(true);
15
16 // ✅ Open Polar Customer Portal
17 // Users can manage subscriptions, view invoices, etc.
18 await authClient.customer.portal();
19
20 // User is redirected to Polar's Customer Portal
21 } catch (error) {
22 console.error("Failed to open portal:", error);
23 toast.error("Failed to open billing portal. Please try again.");
24 } finally {
25 setIsLoading(false);
26 }
27 };
28
29 return (
30 <Button onClick={handleOpenPortal} disabled={isLoading} variant="outline">
31 {isLoading ? "Loading..." : "Manage Billing"}
32 </Button>
33 );
34}
35Example 3: Checking User Subscription Status
Often you need to check if a user has an active subscription before granting access to premium features:
1// src/components/Dashboard.tsx
2"use client";
3
4import { useEffect, useState } from "react";
5import { authClient } from "@/auth-client";
6
7export function Dashboard() {
8 const [subscription, setSubscription] = useState(null);
9 const [isLoading, setIsLoading] = useState(true);
10 const [error, setError] = useState(null);
11
12 useEffect(() => {
13 const getSubscription = async () => {
14 try {
15 // ✅ Get customer state (includes active subscriptions, benefits, meters)
16 const { data: customerState } = await authClient.customer.state();
17
18 if (customerState?.subscriptions?.length > 0) {
19 setSubscription(customerState.subscriptions[0]);
20 }
21 } catch (err) {
22 console.error("Failed to fetch subscription:", err);
23 setError("Failed to load subscription info");
24 } finally {
25 setIsLoading(false);
26 }
27 };
28
29 getSubscription();
30 }, []);
31
32 if (isLoading) {
33 return <div className="p-4">Loading subscription information...</div>;
34 }
35
36 if (error) {
37 return <div className="p-4 text-red-600">{error}</div>;
38 }
39
40 if (!subscription) {
41 return (
42 <div className="p-4">
43 <p className="text-gray-600">You don't have an active subscription yet.</p>
44 <p className="text-sm mt-2">Upgrade your account to access premium features.</p>
45 </div>
46 );
47 }
48
49 return (
50 <div className="p-4 bg-white rounded-lg border">
51 <h2 className="text-lg font-semibold mb-4">Your Subscription</h2>
52 <div className="space-y-2">
53 <p><strong>Status:</strong> {subscription.status}</p>
54 <p><strong>Plan:</strong> {subscription.productName}</p>
55 <p><strong>Next billing:</strong> {new Date(subscription.endsAt).toLocaleDateString()}</p>
56 </div>
57 </div>
58 );
59}
60The authClient.customer.state() method returns everything you need to know about a user's subscription status, including:
This is perfect for conditional rendering—show premium features only if the user has an active subscription.
Example 4: Usage-Based Billing
If you're charging based on usage (API calls, file uploads, etc.), you need to track events:
1// src/lib/usage.ts
2import { authClient } from "@/auth-client";
3
4export async function trackUsage(
5 event: string,
6 metadata: Record<string, number | string | boolean>
7) {
8 try {
9 // ✅ Ingest usage event
10 await authClient.usage.ingest({
11 event, // e.g., "file-upload", "api-call", "email-sent"
12 metadata, // Any custom data: { uploadedFiles: 5, totalSize: 1024000 }
13 });
14 } catch (error) {
15 console.error("Failed to track usage:", error);
16 // Don't throw - usage tracking shouldn't break your app
17 }
18}
19
20// Use it in your components or API routes:
21// await trackUsage("file-upload", { uploadedFiles: 5, totalSize: 1024000 });
22// await trackUsage("api-call", { endpoint: "/users", count: 23 });
23Call this function whenever users perform metered actions in your app. Polar will aggregate this data and charge based on your pricing configuration.
The Most Common Mistakes (And How to Avoid Them)
I've seen developers make the same mistakes repeatedly. Here are the ones to watch out for:
Mistake 1: Importing Polar in Your Client File
This is the number one cause of the "node:module" error:
1// ❌ WRONG - Don't do this!
2"use client";
3import { polar } from "@polar-sh/better-auth"; // ❌ Server-only module
4import { Polar } from "@polar-sh/sdk"; // ❌ Server-only module
5import { createAuthClient } from "better-auth/react";
6
7export const authClient = createAuthClient({
8 plugins: [polarClient()], // ❌ Never use this in client code
9});
10This breaks your build with module errors. The correct approach:
1// ✅ CORRECT
2"use client";
3import { createAuthClient } from "better-auth/react";
4
5export const authClient = createAuthClient({
6 baseURL: "...",
7 // No Polar imports or configuration
8});
9The Polar functionality is automatically available through authClient because it's configured on your server.
Mistake 2: Mismatched Product Slugs
Your checkout button and server configuration must use identical slugs:
1// ❌ WRONG
2// In auth.ts
3checkout({
4 products: [
5 { productId: "123", slug: "monthly-plan" }, // Slug is "monthly-plan"
6 ],
7})
8
9// In component
10await authClient.checkout({
11 slug: "Monthly", // ❌ Different slug!
12});
13This won't cause an error—it just won't work. Always double-check that slugs match exactly:
1// ✅ CORRECT
2// In auth.ts
3checkout({
4 products: [
5 { productId: "123", slug: "Monthly" },
6 ],
7})
8
9// In component
10await authClient.checkout({
11 slug: "Monthly", // ✅ Matches exactly
12});
13Mistake 3: Forgetting to Enable Plugins
The methods you call on the client only work if the corresponding plugin is enabled on your server: