developer.paddle.com

paddle-checkout-web

Add a Paddle Checkout to a Next.js web app — overlay or inline, with event handling, customer pre-fill, and dynamic line item updates.

All-time #8355 Trending #5345 Hot #4002 First seen May 12, 2026
8-week activity · all time api

Installation

$ npx skills add https://developer.paddle.com

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from developer.paddle.com.

npx skills add https://developer.paddle.com

Browse all from developer.paddle.com

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 15,229 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1,300 installs

SKILL.md

Build a Paddle Checkout in Next.js

When to use this skill

Use this skill when adding a hosted Paddle checkout to a Next.js (App Router) app, either as an overlay (modal-style) or inline (embedded within your page). It covers initialization, opening checkout with line items, listening to events, customer pre-fill, the post-checkout redirect, and the throttled updateItems pattern for cart-like UIs.

This skill is client-side only. Pair it with:

  • webhooks to receive transaction.completed / subscription.created events on your server.
  • subscription-sync to mirror Paddle subscription state into your database.
  • sandbox-testing to test the integration end-to-end in the Paddle sandbox.

Prerequisites

You need:

  • A Paddle account, with the sandbox environment active during development.

- Sign up for sandbox: https://sandbox-vendors.paddle.com/ - Sign up for live: https://vendors.paddle.com/

  • At least one product and price set up. Use catalog-setup if you haven't yet — note the price ID (looks like pri_01h...) for use below.
  • Your domain approved for checkout under Paddle > Checkout > Website approval. Domains are approved automatically in sandbox.
  • Your default payment link set under Paddle > Checkout > Checkout settings > Default payment link. You can use https://localhost/ for sandbox, then change later.
  • Two environment variables from Paddle > Developer tools > Authentication:
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_...   # client-side token, safe to expose
NEXT_PUBLIC_PADDLE_ENV=sandbox             # or "production"

If a Paddle MCP server is available to you, call client.clientTokens.create({ name: "Frontend dev token" }) inside an execute to provision the token programmatically rather than asking the user to copy it from the dashboard. Note clientTokens is camelCase.

The Paddle MCP exposes three tools per server (search, execute, reportmissingtool). Workflow: call search to confirm the exact method name and parameter shapes, then call execute with an async function that calls client.<resource>.<operation>(...). Method paths are camelCase (client.clientTokens.create, client.pricingPreview.preview). Body params and response fields are snakecase (taxcategory, productid, unitprice, currencycode). Pagination is { pagination: { hasMore }, data: [...] } with { after: "<lastid>" } — not .next() / .hasMore. Chain multi-step workflows inside one execute; variables don't persist between calls. Hard caps: 50 API calls per execute, 30s timeout, 32KB code.

Install the client library:

npm install @paddle/paddle-js

The Node SDK (@paddle/paddle-node-sdk) is for server-side work — you do not need it for checkout.

Choose your checkout style

Style When to use What the user sees
Overlay Fastest to integrate. Opens over the current page. Modal-style window that covers your page
Inline When you want full control over the surrounding layout and branding. Checkout fields render inside a <div> you control

Both use the same Paddle.Checkout.open() call — the only difference is the displayMode setting and where it renders. Default to overlay unless you need branded inline. Overlay works without any layout changes; inline requires a target element.

Choose your checkout variant

Variant Description
One-page A single-page checkout experience with all fields (customer and payment details) on the same screen.
Multi-page A two-page checkout: customer details are collected on the first page, payment details on the second page.

Multi-page is the default. One-page is recommended for most use cases.

Overlay checkout — the minimum viable integration

This is the fastest path: a button that opens checkout for one price. Recommended.

// app/buy/page.tsx
"use client";

import { initializePaddle, type Paddle } from "@paddle/paddle-js";
import { useEffect, useState } from "react";

export default function BuyPage() {
  const [paddle, setPaddle] = useState<Paddle | null>(null);

  useEffect(() => {
    if (
      !process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ||
      !process.env.NEXT_PUBLIC_PADDLE_ENV
    ) {
      return;
    }
    initializePaddle({
      token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
      environment: process.env.NEXT_PUBLIC_PADDLE_ENV as
        | "sandbox"
        | "production",
    }).then((p) => p && setPaddle(p));
  }, []);

  function openCheckout() {
    paddle?.Checkout.open({
      items: [{ priceId: "pri_01h...", quantity: 1 }],
      settings: {
        variant: "one-page", // or "multi-page"
      },
    });
  }

  return (
    <button onClick={openCheckout} disabled={!paddle}>
      Buy now
    </button>
  );
}

That's it for overlay — no displayMode setting needed (overlay is the default). The user is shown the modal, completes payment, and Paddle handles the success page.

Inline checkout with full event handling

Inline checkout is what you want when you need to render checkout next to other UI (e.g. a price summary, branded layout, custom success state). The full pattern:

// app/checkout/[priceId]/checkout-contents.tsx
"use client";

import {
  type Environments,
  initializePaddle,
  type Paddle,
} from "@paddle/paddle-js";
import type { CheckoutEventsData } from "@paddle/paddle-js/types/checkout/events";
import throttle from "lodash.throttle";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";

interface Props {
  userEmail?: string;
}

export function CheckoutContents({ userEmail }: Props) {
  const { priceId } = useParams<{ priceId: string }>();
  const [quantity, setQuantity] = useState(1);
  const [paddle, setPaddle] = useState<Paddle | null>(null);
  const [checkoutData, setCheckoutData] = useState<CheckoutEventsData | null>(
    null,
  );

  // Throttle updateItems to avoid hammering Paddle on rapid quantity changes.
  const updateItems = useCallback(
    throttle((paddle: Paddle, priceId: string, quantity: number) => {
      paddle.Checkout.updateItems([{ priceId, quantity }]);
    }, 1000),
    [],
  );

  useEffect(() => {
    if (paddle?.Initialized) return;
    if (!process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN) return;

    initializePaddle({
      token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
      environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments,
      eventCallback: (event) => {
        if (event.data && event.name) {
          setCheckoutData(event.data);
        }
      },
      checkout: {
        settings: {
          variant: "one-page",
          displayMode: "inline",
          theme: "dark",
          allowLogout: !userEmail,
          frameTarget: "paddle-checkout-frame",
          frameInitialHeight: 450,
          frameStyle:
            "width: 100%; background-color: transparent; border: none",
          successUrl: "/checkout/success",
        },
      },
    }).then((p) => {
      if (p && priceId) {
        setPaddle(p);
        p.Checkout.open({
          ...(userEmail && { customer: { email: userEmail } }),
          items: [{ priceId, quantity }],
        });
      }
    });
  }, [paddle?.Initialized, priceId, userEmail]);

  useEffect(() => {
    if (paddle?.Initialized && priceId) {
      updateItems(paddle, priceId, quantity);
    }
  }, [paddle, priceId, quantity, updateItems]);

  return (
    <div>
      <PriceSection
        checkoutData={checkoutData}
        quantity={quantity}
        onQuantityChange={setQuantity}
      />
      {/* The class name here MUST match `frameTarget` above. */}
      <div className="paddle-checkout-frame" />
    </div>
  );
}

Three things to notice:

  1. frameTarget is a CSS class name (no leading dot). Whatever you set here, you must also render an element with that exact class. Paddle injects the iframe into it.
  2. eventCallback fires for every event — use the event.name to discriminate (checkout.loaded, checkout.items.updated, checkout.completed, checkout.error, checkout.payment-error, etc.). The starter kit just keeps the latest event data so a sibling component can show price totals.
  3. successUrl is where Paddle redirects after a successful payment. Make this a route that handles post-purchase logic (e.g. shows an order confirmation) — but do not rely on it for provisioning; the webhook is the source of truth.

Customer pre-fill

If your user is already authenticated, pass their email so they don't need to type it:

paddle.Checkout.open({
  customer: { email: "[email protected]" },
  items: [{ priceId: "pri_01h...", quantity: 1 }],
});

To go further, pass an existing Paddle customer ID (created via the API or returned by a previous checkout):

paddle.Checkout.open({
  customer: { id: "ctm_01h..." },
  items: [{ priceId: "pri_01h...", quantity: 1 }],
});

Setting allowLogout: false in the checkout settings prevents the user from signing out of their pre-filled session — useful when you've already authenticated them.

Dynamic line items

To change quantity, swap a price, or add another item without closing the checkout, call Paddle.Checkout.updateItems():

paddle.Checkout.updateItems([
  { priceId: "pri_01h...", quantity: 3 },
  { priceId: "pri_02h...", quantity: 1 },
]);

Always throttle these calls (1 second is a good default — see the lodash.throttle example above). Paddle re-renders the checkout on each call, and unthrottled updates produce a flicker and rate-limit risk.

Reading checkout state

Hook into the eventCallback to drive your own UI (running totals, line item breakdowns, applied discounts). The most useful events:

Event Fires when Common use
checkout.loaded Checkout finishes initial render Hide a loading spinner
checkout.items.updated Line items change (incl. via updateItems) Update a sibling price summary
checkout.customer.created New customer is created during checkout Capture the new customer.id
checkout.payment.selected User picks a payment method Conditionally show region-specific copy
checkout.completed Payment succeeds Trigger a confetti animation, redirect
checkout.error Something went wrong opening checkout Surface a fallback (mailto, support link)
checkout.payment-error A payment attempt failed Show retry guidance — never the raw error

Full list at developer.paddle.com/paddle-js/events.

Post-checkout: redirect vs webhook

When checkout completes, two things happen in parallel:

  1. The user's browser is sent to your successUrl (or your custom event handler).
  2. Paddle fires a transaction.completed (and possibly subscription.created) webhook to your server.

Provisioning belongs in the webhook, not the redirect. The redirect is for UX (showing "Thanks for your order"); the webhook is the durable, retried, signed event you can trust. See webhooks for setup.

Common pitfalls

  • Something went wrong message — typically means the domain wasn't added to the approved domains list in the Paddle dashboard, or the user didn't add a default payment link in the Paddle dashboard.
  • Paddle is not defined — you forgot to await initializePaddle() before calling Paddle.Checkout.open(). The promise resolves with the Paddle object; only then can you open checkout.
  • Checkout doesn't render inline — your frameTarget class name doesn't match a rendered element, or the element isn't in the DOM yet when Checkout.open() runs. Render the target first; open in a useEffect.
  • Sandbox vs production driftNEXTPUBLICPADDLEENV controls which environment Paddle.js talks to. If your client token is for sandbox but you set NEXTPUBLICPADDLEENV=production, checkout will fail to load. Tokens and prices are environment-scoped — sandbox pri_... IDs don't exist in production.
  • Pasting price IDs into client code — fine for a quick prototype, but for a real app load price IDs from the server (after fetching the catalog) so you can swap them without a redeploy.
  • Using the redirect for provisioning — users close tabs, lose connections, or block redirects. Webhooks are the source of truth.
  • Throttling updateItems — without throttling, rapid quantity changes flicker and may rate-limit. 1 second is a sensible default.
  • Calling initializePaddle twice — the SDK warns and refuses on the second call. Guard with paddle?.Initialized (as in the example) or use a singleton pattern.

Verify the integration

  1. Run npm run dev and navigate to your checkout page.
  2. Confirm the checkout loads or the iframe renders and the price matches what you expect.
  3. Use a Paddle sandbox test card — e.g. 4242 4242 4242 4242 with any future expiry and any 3-digit CVC.
  4. Complete the purchase and confirm:

- The browser lands on your successUrl. - The checkout.completed event fired (console.log from eventCallback). - In the Paddle dashboard (sandbox), the transaction shows under Transactions.

  1. To test the webhook side, see sandbox-testing for the simulator.

Related docs