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:Copy
Ask AI
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}
/>
);
}
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:Copy
Ask AI
// src/types/primer.d.ts
import type { CheckoutElement } from '@primer-io/primer-js';
declare global {
namespace JSX {
interface IntrinsicElements {
'primer-checkout': CheckoutElement;
}
}
}
Alternative: Import all Primer component types
Alternative: Import all Primer component types
For projects using multiple Primer components:
Copy
Ask AI
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
- React 18
React 19 passes objects directly as properties:
Copy
Ask AI
const SDK_OPTIONS = { locale: 'en-GB' };
function Checkout({ clientToken }: { clientToken: string }) {
return (
<primer-checkout
client-token={clientToken}
options={SDK_OPTIONS}
/>
);
}
React 18 converts objects to
[object Object] strings. Use a ref instead:Copy
Ask AI
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:- React 19
- React 18
Copy
Ask AI
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}
/>
);
}
Copy
Ask AI
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
Defineoptions objects outside your component or use useMemo. This avoids unnecessary work on every render.
Copy
Ask AI
// ✅ 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
Copy
Ask AI
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
Copy
Ask AI
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
Copy
Ask AI
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)
This pattern is not required. It’s provided for teams who prefer encapsulating logic in reusable hooks. The examples above work perfectly without it.
Copy
Ask AI
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:Copy
Ask AI
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>
);
}
Copy
Ask AI
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 |