shadimage
paddle
libpaddle-clientsource: ok · hit

Paddle client utilities

Paddle.js initialization singleton, SDK type re-exports, checkout event mapper, and SDK-layer hooks used by all client-side Paddle blocks.

npx shadcn add "https://developer.paddle.com/r/paddle-client.json"
free

Where it goes

checkout

Use cases

ecommerce

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@paddle/paddle-js

Registry dependencies

@paddle/paddle-helpers

Files4 · source cached 10/14/2026, 12:33:41 PM

registry/new-york/blocks/paddle-client/lib/paddle-instance.ts
import {
  initializePaddle,
  type Environments,
  type Paddle,
  type PaddleEventData,
} from "@paddle/paddle-js"

type PaddleEventListener = (event: PaddleEventData) => void

// Module-level singleton state
let paddlePromise: Promise<Paddle | undefined> | null = null
let currentToken: string | null = null
const eventListeners = new Set<PaddleEventListener>()

/**
 * Returns a shared Paddle instance, initializing once per token.
 * Uses a dispatcher eventCallback so multiple hooks can subscribe to events.
 */
export function getOrCreatePaddle(
  token: string,
  environment: Environments
): Promise<Paddle | undefined> {
  if (paddlePromise && currentToken === token) {
    return paddlePromise
  }

  currentToken = token
  paddlePromise = initializePaddle({
    token,
    environment,
    eventCallback: (event) => {
      eventListeners.forEach((listener) => listener(event))
    },
  })

  return paddlePromise
}

/** Subscribe to Paddle events. Returns an unsubscribe function. */
export function addPaddleEventListener(listener: PaddleEventListener): () => void {
  eventListeners.add(listener)
  return () => {
    eventListeners.delete(listener)
  }
}
registry/new-york/blocks/paddle-client/lib/paddle-sdk-types.ts
// Types re-exported from @paddle/paddle-js for convenience.
// Import directly from @paddle/paddle-js if you prefer.
export type {
  Paddle,
  Environments,
  PaddleEventData,
  PricePreviewParams,
  PricePreviewResponse,
  CheckoutEventsData,
  CheckoutCustomer,
  TimePeriod,
  CheckoutOpenLineItem,
} from "@paddle/paddle-js"

export { CheckoutEventNames } from "@paddle/paddle-js"

// CheckoutSettings from the SDK, widened to include "express" in the variant
// union. The express variant is a valid Paddle.js variant not yet reflected in
// the SDK's Variant type ('multi-page' | 'one-page').
import type { CheckoutSettings as SDKCheckoutSettings, CheckoutCustomer } from "@paddle/paddle-js"
export type CheckoutSettings = Omit<SDKCheckoutSettings, "variant"> & {
  variant?: SDKCheckoutSettings["variant"] | "express"
}

// ---
// Hook API types
// Used by the hooks in this library. You generally won't need to use these
// directly unless you're building a custom integration on top of the hooks.
// ---

/**
 * Options for opening a Paddle inline checkout.
 *
 * Passed to `openCheckout()` returned by `useCheckout`. Accepts either a
 * `priceId` (legacy, mapped to a single-item checkout) or an `items` array
 * for multi-item checkouts. When both are provided, `items` takes precedence.
 *
 * Note: this is a simplified wrapper over the raw Paddle SDK `CheckoutOpenOptions`.
 * For full control over the checkout, use `paddle.Checkout.open()` directly.
 */
export type OpenCheckoutOptions = {
  priceId: string
  items?: Array<{ priceId: string; quantity?: number }>
  customer?: CheckoutCustomer
  customerAuthToken?: string
  discountCode?: string
  discountId?: string
  customData?: Record<string, unknown>
  /** Must be an absolute URL (starting with `https://` or `http://`) */
  successUrl?: string
}
registry/new-york/blocks/paddle-client/lib/hooks/use-paddle-prices.tsx
"use client"

import {
  type Environments,
  type Paddle,
  type PricePreviewParams,
  type PricePreviewResponse,
} from "@paddle/paddle-js"
import { useEffect, useState, useRef, useCallback } from "react"
import {
  formatBillingCycle,
  formatTrialPeriod,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"
import { getOrCreatePaddle } from "@/registry/new-york/blocks/paddle-client/lib/paddle-instance"
import type { PriceData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"

export type UsePaddlePricesArgs = {
  clientToken: string
  environment?: Environments
  priceIds: string[]
  countryCode?: string
  discountId?: string
}

type PaddlePrices = Record<string, PriceData>

function getPriceAmounts(prices: PricePreviewResponse): PaddlePrices {
  return prices.data.details.lineItems.reduce((acc, item) => {
    const hasDiscount = item.discounts.length > 0
    acc[item.price.id] = {
      total: item.formattedTotals.total,
      originalTotal: hasDiscount ? item.formattedTotals.subtotal : undefined,
      interval: formatBillingCycle(item.price.billingCycle),
      trialPeriod: item.price.trialPeriod ? formatTrialPeriod(item.price.trialPeriod) : undefined,
    }
    return acc
  }, {} as PaddlePrices)
}

export function usePaddlePrices(args: UsePaddlePricesArgs): {
  prices: PaddlePrices
  loading: boolean
  error: Error | null
} {
  const { clientToken, environment = "production", priceIds, countryCode, discountId } = args
  const [paddle, setPaddle] = useState<Paddle | null>(null)
  const [prices, setPrices] = useState<PaddlePrices>({})
  const [loading, setLoading] = useState<boolean>(true)
  const [error, setError] = useState<Error | null>(null)

  // Stable keys — prevent unnecessary re-fetches
  const priceIdsKey = priceIds.join(",")
  const discountIdKey = discountId ?? ""

  // Initialize Paddle once
  useEffect(() => {
    if (paddle || !clientToken) return

    getOrCreatePaddle(clientToken, environment)
      .then((paddleInstance) => {
        if (paddleInstance) {
          setPaddle(paddleInstance)
        }
      })
      .catch((err) => {
        setError(err instanceof Error ? err : new Error("Failed to initialize Paddle"))
        setLoading(false)
      })
  }, [clientToken, environment, paddle])

  // Fetch prices when Paddle is ready or inputs change
  const fetchedRef = useRef<string | null>(null)

  const fetchPrices = useCallback(async () => {
    if (!paddle) return

    const fetchKey = `${priceIdsKey}:${countryCode ?? ""}:${discountIdKey}`
    if (fetchedRef.current === fetchKey) return
    fetchedRef.current = fetchKey

    const priceIdList = priceIdsKey.split(",").filter(Boolean)
    if (priceIdList.length === 0) return

    const paddlePricePreviewRequest: Partial<PricePreviewParams> = {
      items: priceIdList.map((priceId) => ({ priceId, quantity: 1 })),
      ...(countryCode && { address: { countryCode } }),
      ...(discountId && { discountId }),
    }

    setLoading(true)

    try {
      const priceResponse = await paddle.PricePreview(
        paddlePricePreviewRequest as PricePreviewParams
      )
      setPrices(getPriceAmounts(priceResponse))
      setError(null)
    } catch (err) {
      setError(err instanceof Error ? err : new Error("Failed to fetch prices"))
    } finally {
      setLoading(false)
    }
  }, [paddle, priceIdsKey, countryCode, discountIdKey])

  useEffect(() => {
    fetchPrices()
  }, [fetchPrices])

  return { prices, loading, error }
}
registry/new-york/blocks/paddle-client/lib/hooks/use-checkout.tsx
"use client"

import { type Environments, type Theme, type Paddle } from "@paddle/paddle-js"
import { useEffect, useState, useCallback, useRef } from "react"
import {
  CheckoutEventNames,
  type OpenCheckoutOptions,
  type CheckoutSettings,
} from "@/registry/new-york/blocks/paddle-client/lib/paddle-sdk-types"
import type { CheckoutCompleteData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import {
  getOrCreatePaddle,
  addPaddleEventListener,
} from "@/registry/new-york/blocks/paddle-client/lib/paddle-instance"

export type UseCheckoutArgs = {
  clientToken: string
  environment?: Environments
  theme?: Theme
  locale?: string
  showNonExpressPaymentMethods?: boolean
  checkoutSettings: CheckoutSettings
  onComplete?: (data: CheckoutCompleteData) => void
  onError?: (error: Error) => void
}

export function useCheckout(args: UseCheckoutArgs) {
  const {
    clientToken,
    environment = "production",
    theme,
    locale,
    showNonExpressPaymentMethods,
    checkoutSettings,
    onComplete,
    onError,
  } = args

  const [paddle, setPaddle] = useState<Paddle | null>(null)
  const [isReady, setIsReady] = useState(false)

  // Refs avoid stale closures in event listeners
  const onCompleteRef = useRef(onComplete)
  const onErrorRef = useRef(onError)

  useEffect(() => {
    onCompleteRef.current = onComplete
    onErrorRef.current = onError
  }, [onComplete, onError])

  // Subscribe to checkout.completed events
  useEffect(() => {
    const unsubscribe = addPaddleEventListener((event) => {
      if (event.name === CheckoutEventNames.CHECKOUT_COMPLETED && onCompleteRef.current) {
        onCompleteRef.current({
          transactionId: event.data?.transaction_id ?? "",
          customerId: event.data?.customer?.id ?? "",
          customerEmail: event.data?.customer?.email ?? "",
        })
      }
    })

    return unsubscribe
  }, [])

  // Initialize Paddle
  useEffect(() => {
    if (paddle || !clientToken) return

    getOrCreatePaddle(clientToken, environment)
      .then((paddleInstance) => {
        if (paddleInstance) {
          setPaddle(paddleInstance)
          setIsReady(true)
        }
      })
      .catch((error) => {
        if (onErrorRef.current) {
          onErrorRef.current(
            error instanceof Error ? error : new Error("Failed to initialize Paddle")
          )
        }
      })
  }, [clientToken, environment, paddle])

  const openCheckout = useCallback(
    (options: OpenCheckoutOptions) => {
      if (!paddle || !isReady) {
        console.warn("Paddle not initialized yet")
        return
      }

      // Normalise to items array — support both items[] and legacy priceId
      const items =
        options.items && options.items.length > 0
          ? options.items.map((item) => ({ priceId: item.priceId, quantity: item.quantity ?? 1 }))
          : [{ priceId: options.priceId, quantity: 1 }]

      paddle.Checkout.open({
        items,
        ...(options.customerAuthToken
          ? { customerAuthToken: options.customerAuthToken }
          : options.customer
            ? { customer: options.customer }
            : {}),
        ...(options.discountCode
          ? { discountCode: options.discountCode }
          : options.discountId
            ? { discountId: options.discountId }
            : {}),
        ...(options.customData && { customData: options.customData }),
        ...(options.successUrl && { successUrl: options.successUrl }),
        settings: {
          displayMode: checkoutSettings.displayMode,
          variant: checkoutSettings.variant,
          frameTarget: checkoutSettings.frameTarget,
          frameInitialHeight: checkoutSettings.frameInitialHeight,
          frameStyle: checkoutSettings.frameStyle ?? "width: 100%; border: 0;",
          ...(theme && { theme }),
          ...(locale && { locale }),
          // Not yet in @paddle/paddle-js SDK types, landing soon
          ...(showNonExpressPaymentMethods !== undefined && {
            showNonExpressPaymentMethods,
          }),
        } as Record<string, unknown>,
      })
    },
    [paddle, isReady, checkoutSettings, theme, locale, showNonExpressPaymentMethods]
  )

  const closeCheckout = useCallback(() => {
    if (paddle?.Checkout) {
      paddle.Checkout.close()
    }
  }, [paddle])

  const updateItems = useCallback(
    (items: Array<{ priceId: string; quantity: number }>) => {
      if (!paddle || !isReady) {
        console.warn("Paddle not initialized yet")
        return
      }
      ;(
        paddle.Checkout as Record<string, unknown> & { updateItems?: (items: unknown) => void }
      ).updateItems?.(items)
    },
    [paddle, isReady]
  )

  return {
    openCheckout,
    closeCheckout,
    updateItems,
    isReady,
  }
}

More from paddle

Browse all components in this library →