Back to list
tech-with-seth

add-billing

by tech-with-seth

React Router 7 starter with Polar.sh, BetterAuth, Prisma, and Tailwind

1🍴 0📅 Jan 25, 2026

SKILL.md


name: add-billing description: Add Polar billing integration for subscriptions and payments. Use when implementing checkout flows, customer portals, or processing payment webhooks.

Add Billing

Adds Polar billing integration for subscriptions, one-time payments, and customer management.

When to Use

  • Implementing checkout flows
  • Adding customer portal for subscription management
  • Processing payment webhooks
  • Checking subscription status
  • User asks to "add billing", "payments", or "subscriptions"

Environment Variables

POLAR_ACCESS_TOKEN="polar_xxx"      # API access token
POLAR_WEBHOOK_SECRET="whsec_xxx"    # Webhook signing secret
POLAR_SERVER="sandbox"              # "sandbox" or "production"

Architecture

User clicks "Buy" → Checkout Route → Polar Checkout
    ↓
Polar processes payment → Webhook → Update Database
    ↓
User returns → Success URL → Dashboard

Never call polarClient directly in routes. Use model layer functions.

Step 1: Checkout Route

// app/routes/checkout.tsx
import type { Route } from './+types/checkout';
import { Checkout } from '@polar-sh/remix';

export const loader = Checkout({
    accessToken: process.env.POLAR_ACCESS_TOKEN!,
    successUrl: `${process.env.BETTER_AUTH_URL}/dashboard?checkout=success`,
    server: process.env.POLAR_SERVER as 'sandbox' | 'production',
});

// Usage: /checkout?products=prod_xxx&customerEmail=user@example.com

Query Parameters:

ParameterRequiredDescription
productsYesPolar product ID
customerExternalIdNoYour user ID (links to Iridium user)
customerEmailNoPre-fill email

Step 2: Customer Portal

// app/routes/portal.tsx
import type { Route } from './+types/portal';
import { CustomerPortal } from '@polar-sh/remix';
import { requireUser } from '~/lib/session.server';
import { getCustomerByExternalId } from '~/models/polar.server';

export const loader = CustomerPortal({
    accessToken: process.env.POLAR_ACCESS_TOKEN!,
    getCustomerId: async (args) => {
        const user = await requireUser(args.request);
        const customers = await getCustomerByExternalId(user.id);

        if (customers.result.items.length === 0) {
            throw new Response('Customer not found', { status: 404 });
        }

        return customers.result.items[0].id;
    },
    server: process.env.POLAR_SERVER as 'sandbox' | 'production',
});

Step 3: Webhook Handler

// app/routes/api/webhooks/polar.ts
import { Webhooks } from '@polar-sh/remix';
import { updateUserSubscription } from '~/models/user.server';

export const action = Webhooks({
    webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,

    onOrderPaid: async (payload) => {
        const { customer, product } = payload.data;

        if (customer?.externalId) {
            await updateUserSubscription(customer.externalId, {
                polarCustomerId: customer.id,
                productId: product?.id,
                status: 'active',
            });
        }
    },

    onSubscriptionCanceled: async (payload) => {
        const { customer } = payload.data;

        if (customer?.externalId) {
            await updateUserSubscription(customer.externalId, {
                status: 'canceled',
            });
        }
    },

    onSubscriptionRevoked: async (payload) => {
        const { customer } = payload.data;

        if (customer?.externalId) {
            await updateUserSubscription(customer.externalId, {
                status: 'revoked',
                polarCustomerId: null,
            });
        }
    },
});

Step 4: Model Layer

// app/models/polar.server.ts
import { polarClient } from '~/lib/polar';

export function getProducts() {
    return polarClient.products.list({
        organizationId: null,
        isArchived: false,
    });
}

export function getCustomerByExternalId(userId: string) {
    return polarClient.customers.list({
        query: userId,
        limit: 1,
    });
}

export function getCustomerSubscriptions(customerId: string) {
    return polarClient.subscriptions.list({
        customerId,
        active: true,
    });
}

Step 5: Check Subscription Status

// app/models/subscription.server.ts
import { prisma } from '~/db.server';
import { getCustomerSubscriptions } from '~/models/polar.server';

export async function hasActiveSubscription(userId: string): Promise<boolean> {
    const user = await prisma.user.findUnique({
        where: { id: userId },
        select: { polarCustomerId: true },
    });

    if (!user?.polarCustomerId) return false;

    try {
        const subscriptions = await getCustomerSubscriptions(user.polarCustomerId);
        return subscriptions.result.items.length > 0;
    } catch {
        return false;
    }
}

Linking Checkout to User

import { href } from 'react-router';

<a
    href={href('/checkout', {
        products: product.id,
        customerExternalId: user.id,
        customerEmail: user.email,
    })}
    className="btn btn-primary"
>
    Buy {product.name}
</a>

Register Routes

// app/routes.ts
export default [
    route(Paths.CHECKOUT, 'routes/checkout.tsx'),

    layout('routes/authenticated.tsx', [
        route(Paths.PORTAL, 'routes/portal.tsx'),
    ]),

    ...prefix(Paths.API, [
        ...prefix('webhooks', [
            route('polar', 'routes/api/webhooks/polar.ts'),
        ]),
    ]),
] satisfies RouteConfig;

Webhook Events

EventUse Case
onOrderPaidGrant access
onSubscriptionActiveGrant access
onSubscriptionCanceledGrace period
onSubscriptionRevokedRevoke access
onCustomerCreatedSync to database

Testing

# Use sandbox mode
POLAR_SERVER="sandbox"

# Forward webhooks locally
polar webhooks forward --url http://localhost:5173/api/webhooks/polar

Anti-Patterns

  • Calling polarClient directly in routes
  • Not linking checkout to user via customerExternalId
  • Missing webhook error handling
  • Not validating webhook signatures

Full Reference

See .github/instructions/polar.instructions.md for comprehensive documentation.

Score

Total Score

65/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

+5

Reviews

💬

Reviews coming soon