Back to Blog
notion

Integrating Polar Payments with BetterAuth in Next.js

Integrating Polar Payments with BetterAuth in Next.js

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)

  • Runs your backend logic
  • Has access to powerful Node.js APIs
  • Can securely store secrets and API keys
  • Can run database queries
  • Executes backend frameworks like Express or Fastify
  • The Browser (Client)

  • Runs in your users' web browsers
  • Limited to browser APIs
  • Cannot access Node.js modules
  • Cannot store secrets safely
  • Perfect for rendering UI and handling user interactions
  • 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:

    typescript
    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:

    plain text
    1User clicks "Checkout" button
    23React component calls authClient.checkout()
    45Browser sends HTTP request to your server
    67BetterAuth server receives request
    89Polar plugin (server-only) is triggered
    1011Polar SDK communicates with Polar API
    1213Checkout URL is generated
    1415Server responds to browser with checkout URL
    1617Browser redirects user to Polar checkout page
    1819User completes payment on Polar
    2021Polar redirects user back to your successUrl
    22

    Here's a visual representation:

    plain text
    1┌─────────────────────────────────────────────────────────────┐
    2│                   YOUR NEXT.JS APPLICATION                  │
    3└─────────────────────────────────────────────────────────────┘
    45                    ┌─────────┴─────────┐
    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                    └──────────┬────────┘
    2021                    ┌──────────▼──────────────┐
    22                    │  BetterAuth API         │
    23                    │  (Internal Endpoints)   │
    24                    │  - /api/auth/checkout  │
    25                    │  - /api/auth/customer  │
    26                    │  - /api/auth/usage     │
    27                    └──────────┬──────────────┘
    2829                    ┌──────────▼──────────────┐
    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                    └──────────┬──────────────┘
    3738                    ┌──────────▼──────────────┐
    39                    │  Polar SDK              │
    40                    │  (Payment Provider API) │
    41                    └─────────────────────────┘
    42

    The 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:

    bash
    npm install better-auth @polar-sh/better-auth @polar-sh/sdk
    

    Or if you're using pnpm:

    bash
    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:

    bash
    # .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:

    typescript
    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});
    89

    This file does several important things:

  • 96.
    Initializes the Polar SDK with your secret access token
  • 66.
    Configures BetterAuth to use Polar for payments
  • 16.
    Sets up checkout with your product information
  • 70.
    Enables the customer portal for billing management
  • 39.
    Handles webhooks to react to payment events
  • Step 4: Create Your Client Configuration

    Now create src/auth-client.ts. This is intentionally minimal:

    typescript
    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;
    21

    Notice what's NOT here:

  • No Polar SDK import
  • No Polar plugin import
  • No server-side logic
  • 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:

    typescript
    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}
    45

    When a user clicks this button:

  • 76.
    The component calls authClient.checkout({ slug: "Monthly" })
  • 20.
    This sends an HTTP request to your server
  • 38.
    Your server's Polar plugin creates a checkout session
  • 62.
    The user is redirected to Polar's secure checkout page
  • 2.
    After payment, they're redirected back to your success URL
  • Example 2: Opening the Billing Portal

    Users need a way to manage their subscriptions and invoices. Use the billing portal:

    typescript
    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}
    35

    Example 3: Checking User Subscription Status

    Often you need to check if a user has an active subscription before granting access to premium features:

    typescript
    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}
    60

    The authClient.customer.state() method returns everything you need to know about a user's subscription status, including:

  • Active subscriptions
  • Granted benefits
  • Usage meter data
  • Customer information
  • 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:

    typescript
    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 });
    23

    Call 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:

    typescript
    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});
    10

    This breaks your build with module errors. The correct approach:

    typescript
    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});
    9

    The 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:

    typescript
    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});
    13

    This won't cause an error—it just won't work. Always double-check that slugs match exactly:

    typescript
    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});
    13

    Mistake 3: Forgetting to Enable Plugins

    The methods you call on the client only work if the corresponding plugin is enabled on your server:

    Integrating Polar Payments with BetterAuth in Next.js | Your Blog Name