Paddle Billing plus Next.js 16: The complete guide nobody wrote and the mistakes that cost me a week

typescript dev.to

If you've tried to integrate Paddle Billing with Next.js
and hit confusing walls, this guide is for you.

I spent an embarrassing amount of time getting this right.
Here's everything I learned, including the two mistakes
that aren't documented anywhere.


Why Paddle instead of Stripe?

Stripe is excellent if you're in the US or EU. For founders
outside those regions — or anyone who wants global tax
compliance without setting up VAT registrations in multiple
countries — Paddle is a Merchant of Record. They handle
tax calculations, filings, and compliance for 180+ countries
automatically.

The tradeoff is a steeper integration. Let's go through it.


Mistake 1: Using passthrough instead of custom_data

This is the most common mistake and it's not documented
clearly.

When you create a checkout, you need to know which user
or organization completed payment so you can update your
database. The natural instinct is to pass this as a URL
parameter:

// ❌ This looks right but silently fails in Paddle Billing
const checkoutUrl = `${paddleUrl}?passthrough=${JSON.stringify({
  org_id: organization.id
})}`
Enter fullscreen mode Exit fullscreen mode

passthrough is a Paddle Classic parameter. Paddle Billing
(the current API at api.paddle.com) ignores it entirely.
Your webhooks arrive with custom_data: null no matter
what you put in passthrough.

The correct approach is to create the transaction via the
Paddle Billing API and set custom_data there:

// ✅ Correct — custom_data flows through all webhook events
const res = await fetch("https://sandbox-api.paddle.com/transactions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.PADDLE_API_KEY}`,
  },
  body: JSON.stringify({
    items: [{ price_id: priceId, quantity: 1 }],
    customer: { email: user.email },
    custom_data: {
      org_id: organization.id,
      user_id: user.id,
    },
    checkout: {
      url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
    },
  }),
});

const result = await res.json();
const checkoutUrl = result.data?.checkout?.url;
// Looks like: https://yourdomain.com/dashboard/billing?_ptxn=txn_...
Enter fullscreen mode Exit fullscreen mode

Paddle attaches custom_data to every subsequent event
for this subscription — subscription.activated,
subscription.updated, subscription.canceled. Your
webhook reads it like this:

case "subscription.activated": {
  const orgId = event.data?.custom_data?.org_id;
  if (!orgId) {
    console.warn("No org_id in custom_data — skipping");
    break;
  }
  // update your database...
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Not loading Paddle.js

When the checkout route returns a URL like
https://yourdomain.com/dashboard/billing?_ptxn=txn_...,
this is NOT a redirect to Paddle's hosted checkout page.

It's designed for Paddle.js. The library detects the
_ptxn query parameter and opens the checkout overlay
automatically on top of your page.

Without Paddle.js loaded, the user gets redirected to
your own domain with an unrecognised query parameter
and nothing happens. The checkout overlay never appears.

Load Paddle.js in your root layout:

// src/components/providers/paddle-provider.tsx
"use client";
import Script from "next/script";

export function PaddleProvider() {
  const token = process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN;
  const env = process.env.NEXT_PUBLIC_PADDLE_ENV ?? "sandbox";

  return (
    <Script
      src="https://cdn.paddle.com/paddle/v2/paddle.js"
      strategy="afterInteractive"
      onLoad={() => {
        if (env === "sandbox") {
          window.Paddle.Environment.set("sandbox");
        }
        window.Paddle.Initialize({
          token,
          eventCallback(event) {
            if (event.name === "checkout.completed") {
              // Navigate after payment — see Mistake 3
              window.location.replace("/dashboard/billing");
            }
          },
        });
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Add it to your root layout:

// src/app/layout.tsx
import { PaddleProvider } from "@/components/providers/paddle-provider";

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <PaddleProvider />
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

The timing problem after checkout

When checkout.completed fires in Paddle.js, your
webhook hasn't been processed yet. If you navigate to
the billing page immediately, it reads the old plan from
the database.

Fix: poll a lightweight status endpoint until the
webhook has updated the database.

// src/app/api/billing/status/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/auth";
import { prisma } from "@/lib/db";

export async function GET() {
  const token = (await cookies()).get("token")?.value;
  const payload = token ? verifyToken(token) : null;

  if (!payload?.orgId) return NextResponse.json({ active: false });

  const sub = await prisma.subscription.findUnique({
    where: { organizationId: payload.orgId },
    select: { plan: true, status: true },
  });

  const active =
    sub?.status === "ACTIVE" &&
    (sub?.plan === "PRO" || sub?.plan === "ENTERPRISE");

  return NextResponse.json({ active, plan: sub?.plan });
}
Enter fullscreen mode Exit fullscreen mode

Then in your PaddleProvider, poll before navigating:

eventCallback(event) {
  if (event.name === "checkout.completed") {
    let attempts = 0;
    const poll = async () => {
      attempts++;
      const res = await fetch("/api/billing/status");
      const data = await res.json();
      if (data.active) {
        window.location.replace("/dashboard/billing?activated=1");
      } else if (attempts < 10) {
        setTimeout(poll, 1500);
      } else {
        window.location.replace("/dashboard/billing");
      }
    };
    setTimeout(poll, 1000);
  }
}
Enter fullscreen mode Exit fullscreen mode

Complete environment variables

PADDLE_ENV="sandbox"
NEXT_PUBLIC_PADDLE_ENV="sandbox"
PADDLE_API_KEY="pdl_sdbx_apikey_..."
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="test_..."
PADDLE_PRICE_PRO="pri_01..."
PADDLE_PRICE_ENTERPRISE="pri_01..."
PADDLE_WEBHOOK_SECRET=""  # leave blank for local dev
Enter fullscreen mode Exit fullscreen mode

Testing locally

Paddle can't send webhooks to localhost. Test your
webhook handler directly:

curl -X POST http://localhost:3000/api/billing/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "subscription.activated",
    "data": {
      "id": "sub_test",
      "customer_id": "ctm_test",
      "status": "active",
      "items": [{ "price": { "id": "YOUR_PADDLE_PRICE_PRO_ID" } }],
      "custom_data": { "org_id": "YOUR_ORG_ID" },
      "current_billing_period": { "ends_at": "2027-01-01T00:00:00Z" }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

Wrapping up

The three things that aren't documented clearly anywhere:

  1. Use custom_data via the transactions API — not passthrough URL parameters
  2. Load Paddle.js — the checkout URL only works with the library present
  3. Poll for webhook completion before navigating — don't assume the DB is updated immediately

I packaged all of this into a complete Next.js 16 SaaS
boilerplate with multi-tenancy, team management, feature
gating, and Resend email. If you want a head start:

Demo: https://nextsaasstarter.vercel.app/
Boilerplate: https://stackfoundry.gumroad.com/l/retljp

Happy to answer questions in the comments.

Source: dev.to

arrow_back Back to Tutorials