> ## Documentation Index
> Fetch the complete documentation index at: https://primer.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# React integration guide

> Integrate Primer Checkout with React 18 and React 19 applications.

Primer Checkout uses Web Components, which work natively in React. This guide covers React-specific patterns for both React 18 and React 19.

> [!NOTE]
>
> **Using Next.js?** See the [SSR Guide](https://primer.io/docs/checkout/primer-checkout/frameworks/ssr-guide.md) first—you'll need to load Primer on the client side only.

## Quick start

Here's a minimal working example for React 19:

```tsx theme={"dark"}
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](#react-18-pattern) below.

## TypeScript setup

TypeScript doesn't recognize custom web component tags by default. Add this declaration to your project:

```typescript theme={"dark"}
// 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.

> [accordion] Alternative: Import all Primer component types
>
> For projects using multiple Primer components:
>
> ```typescript theme={"dark"}
> 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.

> [tab] React 19
>
> React 19 passes objects directly as properties:
>
> ```tsx theme={"dark"}
> const SDK_OPTIONS = { locale: 'en-GB' };
>
> function Checkout({ clientToken }: { clientToken: string }) {
>   return (
>     <primer-checkout
>       client-token={clientToken}
>       options={SDK_OPTIONS}
>     />
>   );
> }
> ```

> [tab] React 18
>
> React 18 converts objects to `[object Object]` strings. Use a ref instead:
>
> ```tsx theme={"dark"}
> import { useRef, useEffect } from 'react';
>
> const SDK_OPTIONS = { locale: 'en-GB' };
>
> function Checkout({ clientToken }: { clientToken: string }) {
>   const checkoutRef = useRef<PrimerCheckoutComponent>(null);
>
>   useEffect(() => {
>     if (checkoutRef.current) {
>       checkoutRef.current.options = SDK_OPTIONS;
>     }
>   }, []);
>
>   return (
>     <primer-checkout
>       ref={checkoutRef}
>       client-token={clientToken}
>     />
>   );
> }
> ```

| Aspect | React 18 | React 19 |
| - | - | - |
| How to pass `options` | ref + useEffect | JSX prop directly |
| String attributes (`client-token`) | Works normally | Works normally |

## Handling payment events

Listen for payment events to handle success and failure:

> [tab] React 19
>
> ```tsx theme={"dark"}
> import { useEffect, useRef } from 'react';
>
> const SDK_OPTIONS = { locale: 'en-GB' };
>
> function Checkout({ clientToken }: { clientToken: string }) {
>   const checkoutRef = useRef<PrimerCheckoutComponent>(null);
>
>   useEffect(() => {
>     const checkout = checkoutRef.current;
>     if (!checkout) return;
>
>     const handleSuccess = (event: PrimerEvents['primer:payment-success']) => {
>       const { payment } = event.detail;
>       console.log('Payment successful:', payment.id);
>       window.location.href = `/confirmation?order=${payment.orderId}`;
>     };
>
>     const handleFailure = (event: PrimerEvents['primer:payment-failure']) => {
>       const { error } = event.detail;
>       console.error('Payment failed:', error.message);
>       // Error is displayed in checkout UI automatically
>     };
>
>     checkout.addEventListener('primer:payment-success', handleSuccess);
>     checkout.addEventListener('primer:payment-failure', handleFailure);
>
>     return () => {
>       checkout.removeEventListener('primer:payment-success', handleSuccess);
>       checkout.removeEventListener('primer:payment-failure', handleFailure);
>     };
>   }, []);
>
>   return (
>     <primer-checkout
>       ref={checkoutRef}
>       client-token={clientToken}
>       options={SDK_OPTIONS}
>     />
>   );
> }
> ```

> [tab] React 18
>
> ```tsx theme={"dark"}
> import { useEffect, useRef } from 'react';
>
> const SDK_OPTIONS = { locale: 'en-GB' };
>
> function Checkout({ clientToken }: { clientToken: string }) {
>   const checkoutRef = useRef<PrimerCheckoutComponent>(null);
>
>   useEffect(() => {
>     const checkout = checkoutRef.current;
>     if (!checkout) return;
>
>     // Set options (React 18 requires this)
>     checkout.options = SDK_OPTIONS;
>
>     const handleSuccess = (event: PrimerEvents['primer:payment-success']) => {
>       const { payment } = event.detail;
>       console.log('Payment successful:', payment.id);
>       window.location.href = `/confirmation?order=${payment.orderId}`;
>     };
>
>     const handleFailure = (event: PrimerEvents['primer:payment-failure']) => {
>       const { error } = event.detail;
>       console.error('Payment failed:', error.message);
>     };
>
>     checkout.addEventListener('primer:payment-success', handleSuccess);
>     checkout.addEventListener('primer:payment-failure', handleFailure);
>
>     return () => {
>       checkout.removeEventListener('primer:payment-success', handleSuccess);
>       checkout.removeEventListener('primer:payment-failure', handleFailure);
>     };
>   }, []);
>
>   return (
>     <primer-checkout
>       ref={checkoutRef}
>       client-token={clientToken}
>     />
>   );
> }
> ```

## Stable object references

Define `options` objects outside your component or use `useMemo`. This avoids unnecessary work on every render.

```tsx theme={"dark"}
// ✅ 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' }} />;
}
```

> [!NOTE]
>
> 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

```tsx theme={"dark"}
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<PrimerCheckoutComponent>(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

```tsx theme={"dark"}
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

```tsx theme={"dark"}
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<PrimerCheckoutComponent>(null);

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

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

    const handleSuccess = (event: PrimerEvents['primer:payment-success']) => {
      const { payment } = event.detail;
      onSuccess(payment);
      onClose();
    };

    const handleFailure = (event: PrimerEvents['primer:payment-failure']) => {
      const { error } = event.detail;
      console.error('Payment failed:', error.message);
    };

    checkout.addEventListener('primer:payment-success', handleSuccess);
    checkout.addEventListener('primer:payment-failure', handleFailure);

    return () => {
      checkout.removeEventListener('primer:payment-success', handleSuccess);
      checkout.removeEventListener('primer:payment-failure', handleFailure);
    };
  }, [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)

> [!NOTE]
>
> 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:

```tsx theme={"dark"}
import { useRef, useEffect, useState, useCallback } from 'react';

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

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

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

    const handleReady = () => {
      setIsReady(true);
    };

    const handleSuccess = (event: PrimerEvents['primer:payment-success']) => {
      const { payment } = event.detail;
      setIsProcessing(false);
      options.onSuccess?.(payment);
    };

    const handleFailure = (event: PrimerEvents['primer:payment-failure']) => {
      const { error } = event.detail;
      setIsProcessing(false);
      options.onFailure?.(error);
    };

    const handleStateChange = (event: PrimerEvents['primer:state-change']) => {
      setIsProcessing(event.detail.isProcessing);
    };

    checkout.addEventListener('primer:ready', handleReady);
    checkout.addEventListener('primer:payment-success', handleSuccess);
    checkout.addEventListener('primer:payment-failure', handleFailure);
    checkout.addEventListener('primer:state-change', handleStateChange);

    return () => {
      checkout.removeEventListener('primer:ready', handleReady);
      checkout.removeEventListener('primer:payment-success', handleSuccess);
      checkout.removeEventListener('primer:payment-failure', handleFailure);
      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:

```tsx theme={"dark"}
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<PrimerCheckoutComponent>(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 = () => {
      setIsReady(true);
    };

    const handleSuccess = (event: CustomEvent) => {
      const { payment } = event.detail;
      onSuccess?.(payment);
    };

    const handleFailure = (event: CustomEvent) => {
      const { error } = event.detail;
      onFailure?.(error);
    };

    checkout.addEventListener('primer:ready', handleReady);
    checkout.addEventListener('primer:payment-success', handleSuccess);
    checkout.addEventListener('primer:payment-failure', handleFailure);

    return () => {
      checkout.removeEventListener('primer:ready', handleReady);
      checkout.removeEventListener('primer:payment-success', handleSuccess);
      checkout.removeEventListener('primer:payment-failure', handleFailure);
    };
  }, [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:

```tsx theme={"dark"}
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

| Scenario | Solution |
| - | - |
| Static options | Constant outside component |
| Options depend on props | `useMemo` with dependencies |
| React 18 object props | Use ref + useEffect |
| React 19 object props | Pass directly in JSX |
| Handle payment success | Listen for `primer:payment-success` event |
| Track loading state | Listen for `primer:ready` event |
| Track processing state | Listen for `primer:state-change` event |

## See also

> [card] [SSR Guide](https://primer.io/docs/checkout/primer-checkout/frameworks/ssr-guide.md)
>
> Next.js, Nuxt, and SvelteKit patterns

> [card] [SDK Options](https://primer.io/docs/checkout/primer-checkout/configuration/sdk-options.md)
>
> All configuration options

> [card] [Events Guide](https://primer.io/docs/checkout/primer-checkout/configuration/events.md)
>
> Complete event reference

> [card] [Styling](https://primer.io/docs/checkout/primer-checkout/build-your-ui/styling-customization.md)
>
> Customize the appearance


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.