Primer Checkout requires browser APIs and must be loaded on the client side only. This guide shows the patterns for each SSR framework.
Quick Fix
The core pattern is the same for all frameworks—load Primer inside a client-side lifecycle hook:
// Load Primer only on the client
useEffect (() => {
loadPrimer ();
}, []);
That’s it. The sections below show this pattern for each framework.
Next.js
App Router (Recommended)
'use client' ;
import { useEffect } from 'react' ;
import { loadPrimer } from '@primer-io/primer-js' ;
const SDK_OPTIONS = { locale: 'en-GB' };
export default function CheckoutPage () {
useEffect (() => {
loadPrimer ();
}, []);
return (
< primer-checkout
client-token = "your-client-token"
options = { SDK_OPTIONS }
/>
);
}
The 'use client' directive marks the component as client-side only.
Pages Router
import { useEffect } from 'react' ;
import { loadPrimer } from '@primer-io/primer-js' ;
const SDK_OPTIONS = { locale: 'en-GB' };
export default function CheckoutPage () {
useEffect (() => {
if ( typeof window !== 'undefined' ) {
loadPrimer ();
}
}, []);
return (
< primer-checkout
client-token = "your-client-token"
options = { SDK_OPTIONS }
/>
);
}
Using next/dynamic (Alternative)
Disable SSR for the entire checkout component:
// components/Checkout.tsx
'use client' ;
import { useEffect } from 'react' ;
import { loadPrimer } from '@primer-io/primer-js' ;
const SDK_OPTIONS = { locale: 'en-GB' };
export function Checkout ({ clientToken }: { clientToken : string }) {
useEffect (() => {
loadPrimer ();
}, []);
return (
< primer-checkout
client-token = { clientToken }
options = { SDK_OPTIONS }
/>
);
}
// pages/checkout.tsx or app/checkout/page.tsx
import dynamic from 'next/dynamic' ;
const Checkout = dynamic (
() => import ( '@/components/Checkout' ). then (( mod ) => mod . Checkout ),
{
ssr: false ,
loading : () => < div > Loading checkout... </ div >
}
);
export default function CheckoutPage () {
return < Checkout clientToken = "your-client-token" /> ;
}
Nuxt.js
Nuxt 3
< template >
< div >
< primer-checkout client-token = "your-client-token" />
</ div >
</ template >
< script setup >
import { onMounted } from 'vue' ;
onMounted ( async () => {
if ( import . meta . client ) {
const { loadPrimer } = await import ( '@primer-io/primer-js' );
loadPrimer ();
}
});
</ script >
Use import.meta.client in Nuxt 3. The older process.client still works but is a legacy pattern.
< template >
< div >
< primer-checkout client-token = "your-client-token" />
</ div >
</ template >
< script >
export default {
mounted () {
if ( process . client ) {
import ( '@primer-io/primer-js' )
. then (({ loadPrimer }) => {
loadPrimer ();
})
. catch (( error ) => {
console . error ( 'Failed to load Primer:' , error );
});
}
}
}
</ script >
SvelteKit
< script >
import { onMount } from 'svelte' ;
import { browser } from '$app/environment' ;
onMount ( async () => {
if ( browser ) {
const { loadPrimer } = await import ( '@primer-io/primer-js' );
loadPrimer ();
}
});
</ script >
< primer-checkout client-token = "your-client-token" />
Show Loading State
Wait for Primer to load before showing the checkout:
'use client' ;
import { useEffect , useState } from 'react' ;
import { loadPrimer } from '@primer-io/primer-js' ;
export default function CheckoutPage () {
const [ isLoaded , setIsLoaded ] = useState ( false );
useEffect (() => {
loadPrimer (). then (() => setIsLoaded ( true ));
}, []);
if (! isLoaded ) {
return < div > Loading payment options... </ div > ;
}
return < primer-checkout client-token = "your-client-token" /> ;
}
< template >
< div v-if = " ! isLoaded " > Loading payment options... </ div >
< primer-checkout v-else client-token = "your-client-token" />
</ template >
< script setup >
import { ref , onMounted } from 'vue' ;
const isLoaded = ref ( false );
onMounted ( async () => {
if ( import . meta . client ) {
const { loadPrimer } = await import ( '@primer-io/primer-js' );
await loadPrimer ();
isLoaded . value = true ;
}
});
</ script >
< script >
import { onMount } from 'svelte' ;
import { browser } from '$app/environment' ;
let isLoaded = false ;
onMount ( async () => {
if ( browser ) {
const { loadPrimer } = await import ( '@primer-io/primer-js' );
await loadPrimer ();
isLoaded = true ;
}
});
</ script >
{# if ! isLoaded }
< div > Loading payment options... </ div >
{: else }
< primer-checkout client-token = "your-client-token" />
{/ if }
Troubleshooting
”customElements is not defined”
Primer code is running on the server.
Fix: Move loadPrimer() inside a client-side lifecycle hook (useEffect, onMounted, onMount).
“window is not defined”
Code is accessing browser globals during SSR.
Fix: Add an environment check:
if ( typeof window !== 'undefined' ) {
// Browser-only code
}
Components Don’t Render
loadPrimer() hasn’t completed before the component renders.
Fix: Use a loading state to defer rendering until Primer is ready. See Show Loading State above.
TypeScript Errors
TypeScript doesn’t recognize <primer-checkout> as a valid element.
Fix: Add type declarations:
// src/types/primer.d.ts
import { CustomElements } from '@primer-io/primer-js/dist/jsx/index' ;
declare module 'react' {
namespace JSX {
interface IntrinsicElements extends CustomElements {}
}
}
Why SSR Needs This
Server-side rendering frameworks execute code on the server to generate HTML before sending it to the browser. Primer Checkout depends on browser-only APIs:
Web Components API – customElements.define() only exists in browsers
DOM APIs – Component rendering requires the Document Object Model
Window Object – Payment features depend on window and browser globals
Secure Iframes – Card inputs require a browser environment
When SSR frameworks try to execute Primer code on the server, they encounter errors because these APIs don’t exist in Node.js. The solution is to ensure Primer only loads on the client side using lifecycle hooks that run after hydration.
Next Steps