Next.js Integration
This guide covers SSR-safe patterns for integrating Veridex with Next.js, a production payment app that uses dynamic imports, cross-origin passkey authentication, and relayer-based gasless transfers.
The Veridex SDK uses browser APIs (WebAuthn, localStorage) and must only run in Client Components. Always add 'use client' to components using the SDK. For SSR-safe initialization, use dynamic imports as shown below.
Installation
npm install @veridex/sdk ethersSSR-Safe SDK Initialization (Dynamic Import)
The sera/dashboard uses a dynamic import + singleton pattern to prevent the SDK from being loaded during server-side rendering:
// lib/veridex-client.ts
import { WalletManager, CrossOriginAuth } from '@veridex/sdk';
const SERA_HUB_CHAIN = 'base';
const SERA_NETWORK = 'testnet';
const RELAYER_URL = process.env.NEXT_PUBLIC_VERIDEX_RELAYER_URL
|| 'https://relay.veridex.network';
const RELAYER_API_KEY = process.env.NEXT_PUBLIC_RELAYER_API_KEY || '';
let sdkInstance: any = null;
/**
* Lazy-load the SDK to avoid SSR issues.
* The SDK uses browser APIs (WebAuthn, localStorage) that don't exist on the server.
*/
export async function getVeridexSDK(): Promise<any> {
if (!sdkInstance) {
const { createSDK } = await import('@veridex/sdk');
sdkInstance = createSDK(SERA_HUB_CHAIN, {
network: SERA_NETWORK,
relayerUrl: RELAYER_URL,
relayerApiKey: RELAYER_API_KEY,
});
}
return sdkInstance;
}The await import('@veridex/sdk') ensures the SDK bundle is only loaded in the browser, never during SSR. This is critical for Next.js App Router where server components are the default.
Passkey Registration & Authentication
Register a New Passkey
export async function registerPasskey(username: string, displayName?: string) {
const sdk = await getVeridexSDK();
// Register passkey via WebAuthn (prompts biometric)
const credential = await sdk.passkey.register(
username,
displayName || username
);
// Save credential to localStorage for auto-login
sdk.passkey.saveToLocalStorage();
// Sync credential to relayer for cross-device recovery
await sdk.passkey.saveCredentialToRelayer();
// Compute the vault address (deterministic from passkey)
const vaultAddress = sdk.getVaultAddress();
return { credential, vaultAddress };
}Authenticate with Existing Passkey
export async function authenticateWithPasskey() {
const sdk = await getVeridexSDK();
// Discoverable credential authentication (shows passkey picker)
const { credential } = await sdk.passkey.authenticate();
sdk.passkey.saveToLocalStorage();
const vaultAddress = sdk.getVaultAddress();
return { credential, vaultAddress };
}Sign a Challenge
export async function signWithPasskey(challenge: string) {
const sdk = await getVeridexSDK();
const signature = await sdk.passkey.sign(challenge);
return signature;
}Restore Credential from Storage
export async function restoreSDKCredential(): Promise<boolean> {
const sdk = await getVeridexSDK();
// Try localStorage first
const saved = sdk.passkey.loadFromLocalStorage();
if (saved) {
sdk.setCredential(saved);
return true;
}
// Try fetching from relayer (cross-device recovery)
try {
const relayerCred = await sdk.passkey.loadCredentialFromRelayer();
if (relayerCred) {
sdk.setCredential(relayerCred);
sdk.passkey.saveToLocalStorage();
return true;
}
} catch (error) {
console.warn('Could not restore from relayer:', error);
}
return false;
}Cross-Origin Passkey Authentication
When your app runs on a different domain than the passkey's RP ID, use CrossOriginAuth:
import { CrossOriginAuth } from '@veridex/sdk';
export async function authenticateCrossOrigin() {
const crossOriginAuth = new CrossOriginAuth({
authPortalUrl: 'https://auth.veridex.network',
relayerUrl: RELAYER_URL,
rpId: 'veridex.network',
});
// Tries Related Origin Requests (ROR) first, falls back to auth portal popup
const result = await crossOriginAuth.authenticate();
if (result.credential) {
const sdk = await getVeridexSDK();
sdk.setCredential(result.credential);
return result;
}
throw new Error('Cross-origin authentication failed');
}Cross-origin auth uses the Related Origin Requests (opens in a new tab) spec where supported, with an auth portal popup as fallback. This enables passkey sharing across *.veridex.network subdomains.
Gasless Transfers via Relayer
The sera/dashboard uses a prepare → sign → execute flow for gasless payments:
Prepare a Transfer
export async function prepareTransfer(params: {
token: string;
recipient: string;
amount: string;
chain?: number;
}) {
const sdk = await getVeridexSDK();
const prepared = await sdk.prepareTransfer({
targetChain: params.chain || 10004, // Base Sepolia
token: params.token,
recipient: params.recipient,
amount: BigInt(params.amount),
});
return prepared;
}Execute via Relayer (Gasless)
export async function executeSignedTransfer(
transferId: string,
signature: string
) {
const sdk = await getVeridexSDK();
// Execute through relayer — user pays no gas
const result = await sdk.transferViaRelayer({
transferId,
signature,
});
return result;
}Poll for Transaction Completion
export async function pollForTransactionCompletion(
sequenceOrHubTx: string,
maxAttempts = 30,
intervalMs = 2000
): Promise<{ status: string; txHash?: string }> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(
`${RELAYER_URL}/api/v1/tx/status/${sequenceOrHubTx}`
);
const data = await response.json();
if (data.status === 'confirmed' || data.status === 'failed') {
return data;
}
} catch (error) {
console.warn('Poll attempt failed:', error);
}
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
return { status: 'timeout' };
}Balance Queries
export async function getBalance(address: string, token?: string) {
const sdk = await getVeridexSDK();
const portfolio = await sdk.balance.getPortfolioBalance(address, token);
return portfolio;
}Compute Vault Addresses
Vault addresses are deterministic — the same passkey always produces the same address on every chain:
import { WalletManager } from '@veridex/sdk';
export function computeVaultAddress(
keyHash: string,
chainConfig: { vaultFactory: string; vaultImplementation: string }
) {
return WalletManager.computeVaultAddress(
keyHash,
chainConfig.vaultFactory,
chainConfig.vaultImplementation
);
}React Context (Wallet Provider)
The sera/dashboard wraps all SDK operations in a WalletContext that supports multiple connection methods:
// lib/wallet-context.tsx
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import {
getVeridexSDK,
registerPasskey,
authenticateWithPasskey,
signWithPasskey,
restoreSDKCredential,
prepareTransfer,
executeSignedTransfer,
getBalance,
} from './veridex-client';
type ConnectionMethod = 'injected' | 'walletconnect' | 'passkey';
interface WalletContextType {
address: string | null;
isConnected: boolean;
connectionMethod: ConnectionMethod | null;
isLoading: boolean;
// Passkey methods
connectPasskey: () => Promise<void>;
registerNewPasskey: (username: string) => Promise<void>;
signMessage: (message: string) => Promise<string>;
// Payment methods
preparePayment: (params: any) => Promise<any>;
confirmPayment: (transferId: string) => Promise<any>;
// Balance
balance: string | null;
refreshBalance: () => Promise<void>;
disconnect: () => void;
}
const WalletContext = createContext<WalletContextType | undefined>(undefined);
export function WalletProvider({ children }: { children: ReactNode }) {
const [address, setAddress] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [connectionMethod, setConnectionMethod] = useState<ConnectionMethod | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [balance, setBalance] = useState<string | null>(null);
// Auto-restore session on mount
useEffect(() => {
const restore = async () => {
try {
const restored = await restoreSDKCredential();
if (restored) {
const sdk = await getVeridexSDK();
const vault = sdk.getVaultAddress();
setAddress(vault);
setIsConnected(true);
setConnectionMethod('passkey');
}
} catch (error) {
console.warn('Session restore failed:', error);
} finally {
setIsLoading(false);
}
};
restore();
}, []);
const connectPasskey = async () => {
setIsLoading(true);
try {
const { vaultAddress } = await authenticateWithPasskey();
setAddress(vaultAddress);
setIsConnected(true);
setConnectionMethod('passkey');
} finally {
setIsLoading(false);
}
};
const registerNewPasskey = async (username: string) => {
setIsLoading(true);
try {
const { vaultAddress } = await registerPasskey(username);
setAddress(vaultAddress);
setIsConnected(true);
setConnectionMethod('passkey');
} finally {
setIsLoading(false);
}
};
const signMessage = async (message: string) => {
return await signWithPasskey(message);
};
const preparePayment = async (params: any) => {
return await prepareTransfer(params);
};
const confirmPayment = async (transferId: string) => {
// Sign with passkey, then execute via relayer
const signature = await signWithPasskey(transferId);
return await executeSignedTransfer(transferId, signature);
};
const refreshBalance = async () => {
if (!address) return;
try {
const portfolio = await getBalance(address);
setBalance(portfolio?.totalUsdValue?.toFixed(2) || '0.00');
} catch (error) {
console.error('Balance refresh failed:', error);
}
};
const disconnect = () => {
setAddress(null);
setIsConnected(false);
setConnectionMethod(null);
setBalance(null);
localStorage.removeItem('veridex_credentials');
};
return (
<WalletContext.Provider value={{
address, isConnected, connectionMethod, isLoading,
connectPasskey, registerNewPasskey, signMessage,
preparePayment, confirmPayment,
balance, refreshBalance,
disconnect,
}}>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
if (!context) throw new Error('useWallet must be used within WalletProvider');
return context;
}App Router Setup
Layout
// app/layout.tsx
import { WalletProvider } from '@/lib/wallet-context';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<WalletProvider>{children}</WalletProvider>
</body>
</html>
);
}Page Component
// app/page.tsx
'use client';
import { useWallet } from '@/lib/wallet-context';
export default function Home() {
const {
isConnected, address, isLoading,
connectPasskey, registerNewPasskey, disconnect,
balance, refreshBalance,
} = useWallet();
if (isLoading) return <div>Loading...</div>;
if (!isConnected) {
return (
<div className="space-y-4">
<button onClick={connectPasskey}>Sign In with Passkey</button>
<button onClick={() => registerNewPasskey('user@example.com')}>
Create New Wallet
</button>
</div>
);
}
return (
<div>
<p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
<p>Balance: ${balance || '...'}</p>
<button onClick={refreshBalance}>Refresh</button>
<button onClick={disconnect}>Disconnect</button>
</div>
);
}API Routes (Relayer Proxy)
Proxy relayer requests through your Next.js API routes to keep API keys server-side:
// app/api/relayer/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
const RELAYER_URL = process.env.RELAYER_URL || 'https://relay.veridex.network';
const RELAYER_API_KEY = process.env.RELAYER_API_KEY || '';
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const path = params.path.join('/');
const response = await fetch(`${RELAYER_URL}/api/v1/${path}`, {
headers: {
'x-api-key': RELAYER_API_KEY,
'Content-Type': 'application/json',
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}
export async function POST(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const path = params.path.join('/');
const body = await request.json();
const response = await fetch(`${RELAYER_URL}/api/v1/${path}`, {
method: 'POST',
headers: {
'x-api-key': RELAYER_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}Environment Variables
# .env.local (client-side — exposed to browser)
NEXT_PUBLIC_VERIDEX_RELAYER_URL=https://relay.veridex.network
NEXT_PUBLIC_RELAYER_API_KEY=your-public-api-key
# .env.local (server-side — API routes only)
RELAYER_URL=https://relay.veridex.network
RELAYER_API_KEY=your-secret-api-keyServer Components Considerations
Never import @veridex/sdk in Server Components. The SDK requires browser APIs. Use the dynamic import pattern shown above, or keep all SDK usage in 'use client' components.
For server-side data fetching, call the relayer API directly:
// app/vault/[address]/page.tsx (Server Component)
export default async function VaultPage({ params }: { params: { address: string } }) {
const res = await fetch(
`${process.env.RELAYER_URL}/api/v1/balances/${params.address}`,
{ headers: { 'x-api-key': process.env.RELAYER_API_KEY! } }
);
const balances = await res.json();
return (
<div>
<h1>Vault: {params.address}</h1>
<pre>{JSON.stringify(balances, null, 2)}</pre>
</div>
);
}Complete Example
Clone the sera/dashboard for a complete Next.js integration with passkey payments:
git clone https://github.com/Veridex-Protocol/demo
cd demo/sera/dashboard
cp .env.example .env.local
npm install
npm run devOpen http://localhost:3000 (opens in a new tab).