Skip to main content
Primer Checkout uses Web Components, which work natively in React. This guide covers React-specific patterns for both React 18 and React 19.
Using Next.js? See the SSR Guide first—you’ll need to load Primer on the client side only.

Quick Start

Here’s a minimal working example for React 19:
import { useEffect } from 'react';
import { loadPrimer } from '@primer-io/primer-js';

// Define options outside component for stable reference
const SDK_OPTIONS = { locale: 'en-GB' };

export function Checkout({ clientToken }: { clientToken: string }) {
  useEffect(() => {
    loadPrimer();
  }, []);

  return (
    <primer-checkout
      client-token={clientToken}
      options={SDK_OPTIONS}
    />
  );
}
For React 18, you need a ref to pass the options object. See React 18 Pattern below.

TypeScript Setup

TypeScript doesn’t recognize custom web component tags by default. Add this declaration to your project:
// src/types/primer.d.ts
import type { CheckoutElement } from '@primer-io/primer-js';

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'primer-checkout': CheckoutElement;
    }
  }
}
Without this, you’ll see “Property ‘primer-checkout’ does not exist” errors.
For projects using multiple Primer components:
import { CustomElements } from '@primer-io/primer-js/dist/jsx/index';

declare module 'react' {
  namespace JSX {
    interface IntrinsicElements extends CustomElements {}
  }
}

React 18 vs React 19

The key difference is how you pass object properties to web components.
React 19 passes objects directly as properties:
const SDK_OPTIONS = { locale: 'en-GB' };

function Checkout({ clientToken }: { clientToken: string }) {
  return (
    <primer-checkout
      client-token={clientToken}
      options={SDK_OPTIONS}
    />
  );
}
AspectReact 18React 19
How to pass optionsref + useEffectJSX prop directly
String attributes (client-token)Works normallyWorks normally

Handling Payment Events

Set up callbacks after the SDK is ready:
import { useEffect, useRef } from 'react';

const SDK_OPTIONS = { locale: 'en-GB' };

function Checkout({ clientToken }: { clientToken: string }) {
  const checkoutRef = useRef<HTMLElement>(null);

  useEffect(() => {
    const checkout = checkoutRef.current;
    if (!checkout) return;

    const handleReady = (event: CustomEvent) => {
      const primer = event.detail;

      primer.onPaymentSuccess = ({ payment }) => {
        console.log('Payment successful:', payment.id);
        window.location.href = `/confirmation?order=${payment.orderId}`;
      };

      primer.onPaymentFailure = ({ error }) => {
        console.error('Payment failed:', error.message);
        // Error is displayed in checkout UI automatically
      };
    };

    checkout.addEventListener('primer:ready', handleReady);
    return () => checkout.removeEventListener('primer:ready', handleReady);
  }, []);

  return (
    <primer-checkout
      ref={checkoutRef}
      client-token={clientToken}
      options={SDK_OPTIONS}
    />
  );
}

Stable Object References

Define options objects outside your component or use useMemo. This avoids unnecessary work on every render.
// ✅ Good: Constant outside component
const SDK_OPTIONS = { locale: 'en-GB' };

function Checkout() {
  return <primer-checkout options={SDK_OPTIONS} />;
}

// ✅ Good: useMemo for dynamic values
function Checkout({ userLocale }: { userLocale: string }) {
  const options = useMemo(() => ({
    locale: userLocale,
  }), [userLocale]);

  return <primer-checkout options={options} />;
}

// ⚠️ Avoid: Inline objects (creates new reference every render)
function Checkout() {
  return <primer-checkout options={{ locale: 'en-GB' }} />;
}
The SDK uses deep comparison, so inline objects won’t break functionality. But stable references are still recommended to avoid comparison overhead on every render.

Common Patterns

Show Loading While Checkout Initializes

import { useState, useEffect, useRef } from 'react';

const SDK_OPTIONS = { locale: 'en-GB' };

function Checkout({ clientToken }: { clientToken: string }) {
  const [isReady, setIsReady] = useState(false);
  const checkoutRef = useRef<HTMLElement>(null);

  useEffect(() => {
    const checkout = checkoutRef.current;
    if (!checkout) return;

    const handleReady = () => setIsReady(true);
    checkout.addEventListener('primer:ready', handleReady);
    return () => checkout.removeEventListener('primer:ready', handleReady);
  }, []);

  return (
    <div>
      {!isReady && <div className="loading-spinner">Loading checkout...</div>}
      <primer-checkout
        ref={checkoutRef}
        client-token={clientToken}
        options={SDK_OPTIONS}
        style={{ display: isReady ? 'block' : 'none' }}
      />
    </div>
  );
}

Fetch Client Token from Server

import { useState, useEffect } from 'react';
import { loadPrimer } from '@primer-io/primer-js';

const SDK_OPTIONS = { locale: 'en-GB' };

function CheckoutPage() {
  const [clientToken, setClientToken] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    loadPrimer();

    async function fetchToken() {
      try {
        const response = await fetch('/api/create-client-session', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            amount: 1000,
            currency: 'GBP',
          }),
        });
        const data = await response.json();
        setClientToken(data.clientToken);
      } catch (err) {
        setError('Failed to initialize checkout');
      }
    }

    fetchToken();
  }, []);

  if (error) return <div className="error">{error}</div>;
  if (!clientToken) return <div>Loading...</div>;

  return (
    <primer-checkout
      client-token={clientToken}
      options={SDK_OPTIONS}
    />
  );
}

Checkout in a Modal

import { useEffect, useRef } from 'react';

const SDK_OPTIONS = { locale: 'en-GB' };

interface CheckoutModalProps {
  clientToken: string;
  isOpen: boolean;
  onClose: () => void;
  onSuccess: (payment: any) => void;
}

function CheckoutModal({ clientToken, isOpen, onClose, onSuccess }: CheckoutModalProps) {
  const checkoutRef = useRef<HTMLElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    const checkout = checkoutRef.current;
    if (!checkout) return;

    const handleReady = (event: CustomEvent) => {
      const primer = event.detail;

      primer.onPaymentSuccess = ({ payment }) => {
        onSuccess(payment);
        onClose();
      };

      primer.onPaymentFailure = ({ error }) => {
        console.error('Payment failed:', error.message);
      };
    };

    checkout.addEventListener('primer:ready', handleReady);
    return () => checkout.removeEventListener('primer:ready', handleReady);
  }, [isOpen, onClose, onSuccess]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>×</button>
        <primer-checkout
          ref={checkoutRef}
          client-token={clientToken}
          options={SDK_OPTIONS}
        />
      </div>
    </div>
  );
}

Custom Hook (Optional)

This pattern is not required. It’s provided for teams who prefer encapsulating logic in reusable hooks. The examples above work perfectly without it.
Encapsulate checkout logic in a reusable hook:
import { useRef, useEffect, useState, useCallback } from 'react';

interface UsePrimerCheckoutOptions {
  onSuccess?: (payment: any) => void;
  onFailure?: (error: any) => void;
}

function usePrimerCheckout(options: UsePrimerCheckoutOptions = {}) {
  const checkoutRef = useRef<HTMLElement>(null);
  const [isReady, setIsReady] = useState(false);
  const [isProcessing, setIsProcessing] = useState(false);

  useEffect(() => {
    const checkout = checkoutRef.current;
    if (!checkout) return;

    const handleReady = (event: CustomEvent) => {
      const primer = event.detail;
      setIsReady(true);

      primer.onPaymentSuccess = (data) => {
        setIsProcessing(false);
        options.onSuccess?.(data.payment);
      };

      primer.onPaymentFailure = (data) => {
        setIsProcessing(false);
        options.onFailure?.(data.error);
      };
    };

    const handleStateChange = (event: CustomEvent) => {
      setIsProcessing(event.detail.isProcessing);
    };

    checkout.addEventListener('primer:ready', handleReady);
    checkout.addEventListener('primer:state-change', handleStateChange);

    return () => {
      checkout.removeEventListener('primer:ready', handleReady);
      checkout.removeEventListener('primer:state-change', handleStateChange);
    };
  }, [options.onSuccess, options.onFailure]);

  return { checkoutRef, isReady, isProcessing };
}

// Usage
function Checkout({ clientToken }: { clientToken: string }) {
  const { checkoutRef, isReady, isProcessing } = usePrimerCheckout({
    onSuccess: (payment) => {
      window.location.href = `/confirmation?order=${payment.orderId}`;
    },
    onFailure: (error) => {
      console.error('Payment failed:', error.message);
    },
  });

  return (
    <div>
      {!isReady && <div>Loading checkout...</div>}
      {isProcessing && <div>Processing payment...</div>}
      <primer-checkout
        ref={checkoutRef}
        client-token={clientToken}
        options={SDK_OPTIONS}
      />
    </div>
  );
}

Complete Example

A production-ready checkout component with all the patterns combined:
import { useEffect, useRef, useState, useMemo } from 'react';
import { loadPrimer } from '@primer-io/primer-js';

// Types
interface CheckoutProps {
  amount: number;
  currency: string;
  locale?: string;
  onSuccess?: (payment: any) => void;
  onFailure?: (error: any) => void;
}

// Load Primer SDK once at module level
loadPrimer();

export function Checkout({
  amount,
  currency,
  locale = 'en-GB',
  onSuccess,
  onFailure,
}: CheckoutProps) {
  const checkoutRef = useRef<HTMLElement>(null);
  const [clientToken, setClientToken] = useState<string | null>(null);
  const [isReady, setIsReady] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Stable options reference - only changes when locale changes
  const options = useMemo(() => ({ locale }), [locale]);

  // Fetch client token
  useEffect(() => {
    async function fetchToken() {
      try {
        const response = await fetch('/api/create-client-session', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ amount, currency }),
        });

        if (!response.ok) throw new Error('Failed to create session');

        const data = await response.json();
        setClientToken(data.clientToken);
      } catch (err) {
        setError('Unable to initialize checkout. Please try again.');
      }
    }

    fetchToken();
  }, [amount, currency]);

  // Set up event handlers
  useEffect(() => {
    const checkout = checkoutRef.current;
    if (!checkout || !clientToken) return;

    // React 18: Set options via ref
    checkout.options = options;

    const handleReady = (event: CustomEvent) => {
      const primer = event.detail;
      setIsReady(true);

      primer.onPaymentSuccess = ({ payment }) => {
        onSuccess?.(payment);
      };

      primer.onPaymentFailure = ({ error }) => {
        onFailure?.(error);
      };
    };

    checkout.addEventListener('primer:ready', handleReady);
    return () => checkout.removeEventListener('primer:ready', handleReady);
  }, [clientToken, options, onSuccess, onFailure]);

  // Error state
  if (error) {
    return (
      <div className="checkout-error">
        <p>{error}</p>
        <button onClick={() => window.location.reload()}>Try Again</button>
      </div>
    );
  }

  // Loading state
  if (!clientToken) {
    return <div className="checkout-loading">Preparing checkout...</div>;
  }

  return (
    <div className="checkout-container">
      {!isReady && <div className="checkout-loading">Loading payment methods...</div>}
      <primer-checkout
        ref={checkoutRef}
        client-token={clientToken}
        style={{ display: isReady ? 'block' : 'none' }}
      />
    </div>
  );
}
Usage:
function PaymentPage() {
  return (
    <Checkout
      amount={2999}
      currency="GBP"
      locale="en-GB"
      onSuccess={(payment) => {
        window.location.href = `/order/${payment.orderId}/confirmation`;
      }}
      onFailure={(error) => {
        console.error('Payment failed:', error.diagnosticsId);
      }}
    />
  );
}

Quick Reference

ScenarioSolution
Static optionsConstant outside component
Options depend on propsuseMemo with dependencies
React 18 object propsUse ref + useEffect
React 19 object propsPass directly in JSX
Handle payment successprimer.onPaymentSuccess callback
Track loading stateListen for primer:ready event
Track processing stateListen for primer:state-change event

Next Steps