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.
Alternative: Import all Primer component types
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 }
/>
);
}
React 18 converts objects to [object Object] strings. Use a ref instead: import { useRef , useEffect } from 'react' ;
const SDK_OPTIONS = { locale: 'en-GB' };
function Checkout ({ clientToken }: { clientToken : string }) {
const checkoutRef = useRef < HTMLElement >( 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
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 }
/>
);
}
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 ;
// Set options (React 18 requires this)
checkout . options = SDK_OPTIONS ;
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 );
};
};
checkout . addEventListener ( 'primer:ready' , handleReady );
return () => checkout . removeEventListener ( 'primer:ready' , handleReady );
}, []);
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.
// ✅ 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
Scenario Solution Static options Constant outside component Options depend on props useMemo with dependenciesReact 18 object props Use ref + useEffect React 19 object props Pass directly in JSX Handle payment success primer.onPaymentSuccess callbackTrack loading state Listen for primer:ready event Track processing state Listen for primer:state-change event
Next Steps