This page walks through building a complete checkout in React / Next.js on the Headless SDK. New to the SDK? Read How it works first for the architecture and the role of each seam.You build four things, all shown below:
A server proxy — Route Handlers that forward to the Engine with your secret key.
A browser transport — points the runtime at those routes.
A wallet integration — Reown AppKit, adapted into the WalletProvider seam.
A signer — turns the connected wallet into signing capability.
Then usePaymentSession ties them together and gives you a snapshot to render.
# The Headless SDKnpm install @walletconnect/pay-core @walletconnect/pay-state \ @walletconnect/pay-react @walletconnect/pay-appkit# Reown AppKit + adapters for wallet connection (EVM via Wagmi, plus Solana)npm install @reown/appkit @reown/appkit-adapter-wagmi @reown/appkit-adapter-solana \ wagmi viem @solana/web3.js @tanstack/react-query
@walletconnect/pay-appkit requires a Reown AppKit version that exposes the WalletConnect URI on its public state (used to render the pairing QR). Pin @reown/appkit (and its adapters) to the same version, at or above the SDK’s peer range — check the pay-appkit peer dependencies for the current minimum. All @reown/appkit* packages must share one version.
Construct an AppKit instance once on the client, in headless mode (no built-in modal — you render your own wallet picker). Pass the same networks array to AppKit so a connected wallet’s address expands across the chains the Engine quotes against.
components/providers.tsx
'use client'import type { AppKit } from '@walletconnect/pay-appkit' // type re-exported by the SDKimport { SolanaAdapter } from '@reown/appkit-adapter-solana'import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'import { mainnet, polygon, arbitrum, optimism, base, solana, type AppKitNetwork } from '@reown/appkit/networks'import { createAppKit } from '@reown/appkit/react'import { QueryClient, QueryClientProvider } from '@tanstack/react-query'import { createContext, useContext, useEffect, useState } from 'react'import { WagmiProvider } from 'wagmi'export const networks: [AppKitNetwork, ...AppKitNetwork[]] = [ mainnet, polygon, arbitrum, optimism, base, solana]const projectId = process.env.NEXT_PUBLIC_APPKIT_PROJECT_ID ?? ''let wagmiAdapter: WagmiAdapter | null = nulllet appKitInstance: AppKit | null = nullfunction initAppKit() { if (wagmiAdapter && appKitInstance) return { adapter: wagmiAdapter, appKit: appKitInstance } if (typeof window === 'undefined' || !projectId) return null wagmiAdapter = new WagmiAdapter({ networks, projectId }) appKitInstance = createAppKit({ adapters: [wagmiAdapter, new SolanaAdapter()], networks, projectId, features: { headless: true }, // no modal — you own the picker metadata: { name: 'Acme Pay', description: 'Headless checkout', url: typeof window !== 'undefined' ? window.location.origin : 'https://example.com', icons: [] } }) return { adapter: wagmiAdapter, appKit: appKitInstance }}const AppKitContext = createContext<AppKit | null>(null)export function useAppKit(): AppKit { const appKit = useContext(AppKitContext) if (!appKit) throw new Error('useAppKit must be used within <Providers>') return appKit}export function Providers({ children }: { children: React.ReactNode }) { const [state, setState] = useState<{ adapter: WagmiAdapter; appKit: AppKit } | null>(null) const [queryClient] = useState(() => new QueryClient()) useEffect(() => setState(initAppKit()), []) if (!state) return <div>Initializing…</div> return ( <WagmiProvider config={state.adapter.wagmiConfig}> <QueryClientProvider client={queryClient}> <AppKitContext.Provider value={state.appKit}>{children}</AppKitContext.Provider> </QueryClientProvider> </WagmiProvider> )}
Call createAppKitonce at module scope (guarded as above), never inside a component render. Keep every @reown/appkit* package on the same version.
@walletconnect/pay-appkit/react turns the AppKit instance into the runtime’s WalletProvider seam and a ready-made wallet-picker controller (list, search, pagination, the pairing QR URI). One hook gives you both.
import { useAppKitWalletProvider } from '@walletconnect/pay-appkit/react'const { wallet, // ← the WalletProvider seam to hand the runtime wallets, // wallet list for your picker connectedWallets, // currently connected, per namespace supportedNamespaces, wcUri, // WalletConnect URI for the QR getWcUri, // generate a generic pairing URI eagerly searchQuery, setSearchQuery, hasMore, loadMore, isFetchingWallets, isInitialized} = useAppKitWalletProvider(appKit, { wcPayUrl: typeof window !== 'undefined' ? window.location.href : undefined})
The signing strategies (EVM + Solana) live in @walletconnect/pay-state; they just need the wallet’s providers, which only your host can supply. Wire them into a Signer:
lib/signer.ts
import { loadSolanaWeb3 } from '@walletconnect/pay-appkit'import { EvmSigningStrategy, SolanaSigningStrategy, signOptionActions, type ActionRange, type EIP1193Provider, type PaymentOptionExtended, type Signer, type SignPaymentResult, type SigningStrategy, type SolanaProvider, type WalletProvider} from '@walletconnect/pay-state'export function createAppKitSigner(wallet: WalletProvider): Signer { return { signActions(option: PaymentOptionExtended, range?: ActionRange): Promise<SignPaymentResult[]> { const strategies: SigningStrategy[] = [] const evm = wallet.getProvider({ namespace: 'eip155', chainId: '1' }) if (evm) { strategies.push( new EvmSigningStrategy(evm as EIP1193Provider, { switchNetwork: caip => wallet.switchNetwork(caip), getActiveChainId: () => { const caip = wallet.getAccounts().eip155?.caipAddress const [ns, chain] = caip?.split(':') ?? [] return chain ? `${ns}:${chain}` : undefined } }) ) } const sol = wallet.getProvider({ namespace: 'solana', chainId: 'mainnet' }) strategies.push(new SolanaSigningStrategy(sol as SolanaProvider | null, { loadWeb3: loadSolanaWeb3 })) return signOptionActions(option, strategies, range) } }}
snapshot.state is a single string you switch on. Each state maps to one piece of UI; the named actions advance the flow.
switch (snapshot.state) { case 'ReadyForWallet': // Show the QR (snapshot is connecting-agnostic; QR comes from getWcUri/wcUri) // and a wallet picker. On pick: return <WalletPicker wallets={wallets} onPick={(w) => connectWallet(w, w.namespaces[0])} /> case 'ConnectingWallet': return <Spinner label="Connecting…" /> case 'LoadingOptions': return <Spinner label="Finding payment options…" /> case 'OptionsReady': return ( <OptionList options={snapshot.options} onSelect={(opt: PaymentOptionExtended, rank: number) => selectOption(opt, rank)} /> ) case 'NoOptions': return <Empty label="No payment options for this wallet." /> case 'InformationCapture': // Render snapshot.collectData.fields, then: return <KycForm fields={snapshot.collectData?.fields} onSubmit={submitInfoCapture} /> case 'OptionSelected': case 'RequiresApproval': return ( <button onClick={() => confirmSelection()}> {snapshot.requiresApproval ? 'Approve & pay' : 'Confirm'} </button> ) case 'AwaitingWalletApproval': return <Spinner label="Approve in your wallet…" /> case 'WaitingForConfirmation': return <Spinner label="Submitting payment…" /> case 'Succeeded': return <Success payment={snapshot.payment} /> case 'Failed': case 'PaymentExpired': case 'PaymentCancelled': case 'InvalidPayment': case 'SanctionedUser': return <Failure state={snapshot.state} error={snapshot.signingError} />}
That’s a full gateway. Connect → options → (optional KYC) → confirm → sign → settle, all driven by the runtime; you only render and call actions.
# Reown AppKit project ID — required for wallet connection / QR pairing (public)NEXT_PUBLIC_APPKIT_PROJECT_ID=# WalletConnect Pay Engine — server-side only, NEVER exposed to the browserWCP_API_URL=https://staging.api.pay.walletconnect.orgWCP_WALLET_API_KEY=
Every React API here wraps a framework-agnostic core. A vanilla / Vue / Svelte host uses createPaymentController (pay-state) + createAppKitWalletList (pay-appkit) directly: