Skip to Content
Full-Stack React Guide

Full-Stack React Guide

This guide demonstrates how to build a production-ready, multi-chain Next.js App Router application integrating EVM, Solana, Nova UI components, and Quasar Cloud Sync.


🎨 1. Global CSS Styles Import (layout.tsx)

To style Nova UI components (ConnectButton, transaction toasts, modal dialogs), import the bundled CSS stylesheet into your global CSS file (e.g. src/styles/globals.css) or root layout:

In your global CSS file (src/styles/globals.css):

/* src/styles/globals.css */ @import '@tuwaio/sdk/styles/all.css'; /* Optional: if your dApp uses Tailwind CSS v4 */ @import 'tailwindcss';

Or directly in your root React layout (src/app/layout.tsx):

// src/app/layout.tsx import '@tuwaio/sdk/styles/all.css'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ); }

Option B: Granular Individual Styles

If you prefer to include only specific module styles:

/* Import individual stylesheets as needed */ @import '@tuwaio/sdk/styles/nova-core.css'; @import '@tuwaio/sdk/styles/nova-connect.css'; @import '@tuwaio/sdk/styles/nova-transactions.css'; /* Optional: if your dApp uses Tailwind CSS v4 */ @import 'tailwindcss';

Styling & Tailwind CSS Note: All Nova UI components (ConnectButton, modals, toasts) ship with fully compiled, self-contained styles inside @tuwaio/sdk/styles/all.css. Installing Tailwind CSS is optional — we use Tailwind utility classes in our code examples for dApp layout structure, but you are free to use any styling solution (CSS Modules, Styled Components, or Plain CSS). If you do use Tailwind CSS v4 in your project, remember to include @import 'tailwindcss'; in your global CSS file.


📝 2. Define Transaction Union Types (types.ts)

// src/types.ts import type { Transaction } from '@tuwaio/sdk/pulsar'; export enum AppTxType { SWAP = 'SWAP', } export type SwapTx = Transaction & { type: AppTxType.SWAP; payload: { tokenIn: string; tokenOut: string; amount: number }; }; export type TransactionUnion = SwapTx;

🔒 3. Backend SIWX Authentication API (route.ts & authStores.ts)

3.1 Creating Backend Auth Stores (src/lib/authStores.ts)

createSiwxApiHandler requires durable stores for session storage and single-use nonce consumption:

// src/lib/authStores.ts (Production with Redis) import Redis from 'ioredis'; import type { SiwxSessionStore, SiwxNonceStore, SiwxSession, SiwxSessionRecord } from '@tuwaio/sdk/siwx/server'; import { generateServerNonce } from '@tuwaio/sdk/siwx/server'; const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); export const sessionStore: SiwxSessionStore = { async create({ session, ttlSeconds }: { session: SiwxSession; ttlSeconds: number }): Promise<SiwxSessionRecord> { const id = generateServerNonce(); const createdAt = Date.now(); const expiresAt = createdAt + ttlSeconds * 1000; const record: SiwxSessionRecord = { id, session, createdAt, expiresAt }; await redis.set(`siwx:session:${id}`, JSON.stringify(record), 'EX', ttlSeconds); return record; }, async get(id: string): Promise<SiwxSessionRecord | null> { const data = await redis.get(`siwx:session:${id}`); return data ? JSON.parse(data) : null; }, async bindSubject(id: string, subjectId: string): Promise<boolean> { const record = await this.get(id); if (!record) return false; record.subjectId = subjectId; const ttl = Math.max(1, Math.floor((record.expiresAt - Date.now()) / 1000)); await redis.set(`siwx:session:${id}`, JSON.stringify(record), 'EX', ttl); return true; }, async revoke(id: string): Promise<void> { await redis.del(`siwx:session:${id}`); }, }; export const nonceStore: SiwxNonceStore = { async issue({ nonce, ttlSeconds }: { nonce: string; ttlSeconds: number }): Promise<void> { await redis.set(`siwx:nonce:${nonce}`, '1', 'EX', ttlSeconds); }, async consume({ nonce }: { nonce: string }): Promise<boolean> { const value = await redis.getdel(`siwx:nonce:${nonce}`); return value !== null; }, };

Development / In-Memory Store Example (Zero Infrastructure):

// src/lib/authStores.dev.ts (Local testing only) import { MemorySiwxSessionStore, MemorySiwxNonceStore } from '@tuwaio/sdk/siwx/server'; export const sessionStore = new MemorySiwxSessionStore(); export const nonceStore = new MemorySiwxNonceStore();

3.2 Next.js App Router Route Handler

Create a Next.js App Router API route to handle CAIP-122 SIWX verification and session management automatically.

// src/app/api/siwx/[...siwx]/route.ts import { createSiwxApiHandler } from '@tuwaio/sdk/siwx/server-next'; import { sessionStore, nonceStore } from '@/lib/authStores'; // Production Profile: Durable server-side sessions with atomic nonce consumption const handler = createSiwxApiHandler({ sessionStore, nonceStore, policy: { expectedDomain: 'app.tuwa.io', requireExpirationTime: true, }, }); export const { GET, POST, DELETE } = handler;

For Stateless Demo Environments (HMAC-SHA256 Signed Tokens):

import { createStatelessDemoSiwxHandler } from '@tuwaio/sdk/siwx/server-next'; const handler = createStatelessDemoSiwxHandler({ signingSecret: process.env.SIWX_DEMO_SIGNING_SECRET!, policy: { expectedDomain: 'app.tuwa.io' }, }); export const { GET, POST, DELETE } = handler;

☁️ 4. Backend Server Actions (actions.ts)

Server Actions read and verify the active SIWX session strictly server-side using secure HTTP-Only cookies. Never trust a client-provided session object.

// src/app/actions.ts 'use server'; import { cookies } from 'next/headers'; import { Quasar } from '@tuwaio/quasar-sdk'; import { isSessionMatchingTarget } from '@tuwaio/sdk/siwx'; import { getSiwxServerSession } from '@tuwaio/sdk/siwx/server'; import { sessionStore } from '@/lib/authStores'; import { TransactionUnion } from '@/types'; const quasar = new Quasar({ secretKey: process.env.QUASAR_SDK_SK ?? '' }); export async function syncTransaction(tx: TransactionUnion) { const session = await getSiwxServerSession({ cookieSource: await cookies(), sessionStore, }); if (!session) { return { success: false, reason: 'unauthenticated' }; } // Validate session address matches transaction sender to prevent quota draining if (tx.from && !isSessionMatchingTarget(session, tx.from, tx.chainId)) { return { success: false, reason: 'session_mismatch' }; } try { await quasar.pulsar.syncCreate(tx, 'My App'); return { success: true }; } catch (error) { console.warn('[Quasar Engine Sync Error]', error instanceof Error ? error.message : error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } export interface GetHistoryParams { walletAddress: string; page?: number; limit?: number; chainId?: string; appName?: string; } export async function getHistory(params: GetHistoryParams) { const session = await getSiwxServerSession({ cookieSource: await cookies(), sessionStore, }); if (!session || !isSessionMatchingTarget(session, params.walletAddress, params.chainId)) { return { docs: [], totalDocs: 0, limit: params.limit ?? 10, page: params.page ?? 1, totalPages: 1, hasNextPage: false, hasPrevPage: false, }; } try { return await quasar.pulsar.getHistory(params); } catch (error) { console.warn('[Quasar Engine GetHistory Error]', error instanceof Error ? error.message : error); return { docs: [], totalDocs: 0, limit: params.limit ?? 10, page: params.page ?? 1, totalPages: 1, hasNextPage: false, hasPrevPage: false, }; }

For Stateless Demo Environments (HMAC-SHA256 Token Verification):

In sandboxes or zero-infrastructure demos where Redis is not available, pass signingSecret instead of sessionStore:

async function getVerifiedSession() { return getSiwxServerSession({ cookieSource: await cookies(), signingSecret: process.env.SIWX_DEMO_SIGNING_SECRET!, cookieName: 'siwx-demo-session', }); }

⚙️ 5. Application Configuration (appConfig.ts)

Configure EVM chains, Wagmi connectors, default transports, and Solana RPC endpoints using SDK subpath imports:

// src/configs/appConfig.ts import { createDefaultTransports, impersonated } from '@tuwaio/evm-sdk/satellite'; import { createConfig, injected } from '@wagmi/core'; import { type Chain, mainnet, sepolia } from 'viem/chains'; export const solanaRPCUrls = { 'solana:mainnet': 'https://api.mainnet-beta.solana.com', 'solana:devnet': 'https://api.devnet.solana.com', }; export const appEVMChains = [mainnet, sepolia] as readonly [Chain, ...Chain[]]; export const wagmiConfig = createConfig({ connectors: [injected(), impersonated({})], transports: createDefaultTransports(appEVMChains), chains: appEVMChains, ssr: true, syncConnectedChain: true, });

⚡ 6. Headless Tracking Store (usePulsarStore.ts)

// src/hooks/usePulsarStore.ts 'use client'; import { createPulsarStore, createTxInMemoryStore, createBoundedUseStore } from '@tuwaio/sdk/pulsar'; import { pulsarEvmAdapter } from '@tuwaio/evm-sdk/pulsar'; import { pulsarSolanaAdapter } from '@tuwaio/solana-sdk/pulsar'; import { preFlightTxCheck } from '@tuwaio/quasar-sdk'; import { getHistory, syncTransaction } from '@/app/actions'; import { wagmiConfig, appEVMChains, solanaRPCUrls } from '@/configs/appConfig'; import { TransactionUnion } from '@/types'; const storageName = 'transactions-tracking-storage'; const initialStore = createPulsarStore<TransactionUnion>({ name: storageName, adapter: [pulsarEvmAdapter(wagmiConfig, appEVMChains), pulsarSolanaAdapter({ rpcUrls: solanaRPCUrls })], beforeTxProcess: async () => { await preFlightTxCheck(); }, onRemoteCreate: async (tx) => { try { await syncTransaction(tx as TransactionUnion); } catch (err) { console.error('[PulsarHook] Remote sync failed:', err); throw err; // Rethrow to inform pulsar-core that sync failed } }, }); export const usePulsarStore = createBoundedUseStore(initialStore); const pulsarInMemoryStore = createTxInMemoryStore<TransactionUnion>({ localTransactionsPool: initialStore.getState().transactionsPool, reconcileUnsyncedTransactions: initialStore.getState().reconcileUnsyncedTransactions, getHistory: async ({ page, walletAddress }) => { try { const history = await getHistory({ walletAddress, page, limit: 10, appName: 'My App' }); if (!history) return null; return { ...history, docs: history.docs as TransactionUnion[] }; } catch (error) { console.error('[PulsarHook] Failed to fetch history:', error); throw error; } }, onHistoryFetched: async (remoteTxs) => { await initialStore.getState().injectExternalPendingTxs(remoteTxs); }, }); initialStore.subscribe((s) => pulsarInMemoryStore.getState().syncWithLocalPool(s.transactionsPool)); export const usePulsarInMemoryStore = createBoundedUseStore(pulsarInMemoryStore);

📺 7. Nova Transactions Provider (NovaTransactionsProvider.tsx)

// src/providers/NovaTransactionsProvider.tsx 'use client'; import { useSatelliteConnectStore } from '@tuwaio/sdk/satellite'; import { useInitializeTransactionsPool, type TxInMemoryPagination } from '@tuwaio/sdk/pulsar'; import { getAdapterFromConnectorType } from '@tuwaio/sdk/orbit'; import { NovaTransactionsProvider as NTP } from '@tuwaio/sdk/nova-transactions/providers'; import { usePulsarInMemoryStore, usePulsarStore } from '@/hooks/usePulsarStore'; export function NovaTransactionsProvider({ pagination }: { pagination: TxInMemoryPagination }) { const initialTx = usePulsarStore((s) => s.initialTx); const closeTxTrackedModal = usePulsarStore((s) => s.closeTxTrackedModal); const executeTxAction = usePulsarStore((s) => s.executeTxAction); const initializeTransactionsPool = usePulsarStore((s) => s.initializeTransactionsPool); const activeConnection = useSatelliteConnectStore((s) => s.activeConnection); const getAdapter = usePulsarStore((s) => s.getAdapter); const transactionsPool = usePulsarInMemoryStore((s) => s.transactionsPool); useInitializeTransactionsPool({ initializeTransactionsPool }); return ( <NTP transactionsPool={transactionsPool} initialTx={initialTx} closeTxTrackedModal={closeTxTrackedModal} executeTxAction={executeTxAction} connectedWalletAddress={activeConnection?.isConnected ? activeConnection.address : undefined} connectedAdapterType={getAdapterFromConnectorType(activeConnection?.connectorType ?? 'evm:')} adapter={getAdapter()} pagination={pagination} /> ); }

🚀 8. Assembling Application Providers (AppProviders.tsx)

// src/providers/AppProviders.tsx 'use client'; import { SatelliteConnectProvider } from '@tuwaio/sdk/satellite'; import { NovaConnectProvider } from '@tuwaio/sdk/nova-connect'; import { satelliteEVMAdapter } from '@tuwaio/evm-sdk/satellite'; import { EVMConnectorsWatcher } from '@tuwaio/evm-sdk/nova-connect'; import { satelliteSolanaAdapter } from '@tuwaio/solana-sdk/satellite'; import { SolanaConnectorsWatcher } from '@tuwaio/solana-sdk/nova-connect'; import { useSiwxSession } from '@tuwaio/sdk/siwx'; import { appEVMChains, solanaRPCUrls, wagmiConfig } from '@/configs/appConfig'; import { usePulsarInMemoryStore, usePulsarStore } from '@/hooks/usePulsarStore'; import { NovaTransactionsProvider } from '@/providers/NovaTransactionsProvider'; export function AppProviders({ children }: { children: React.ReactNode }) { const transactionsPool = usePulsarInMemoryStore((s) => s.transactionsPool); const getAdapter = usePulsarStore((s) => s.getAdapter); const isLoading = usePulsarInMemoryStore((s) => s.isLoading); const isError = usePulsarInMemoryStore((s) => s.isError); const currentPage = usePulsarInMemoryStore((s) => s.currentPage); const hasMore = usePulsarInMemoryStore((s) => s.hasMore); const fetchNextPage = usePulsarInMemoryStore((s) => s.fetchNextPage); const fetchInitial = usePulsarInMemoryStore((s) => s.fetchInitial); const pagination = { isLoading, isError, currentPage, hasMore, fetchNextPage }; // Watch SIWX session to keep connection state aligned const siwxSession = useSiwxSession(); return ( <SatelliteConnectProvider adapter={[satelliteEVMAdapter(wagmiConfig, appEVMChains), satelliteSolanaAdapter({ rpcUrls: solanaRPCUrls })]} autoConnect={true} callbackAfterConnected={async (connection) => { // Fetch history slightly after connection setTimeout(() => fetchInitial(connection.address), 2000); }} > <EVMConnectorsWatcher wagmiConfig={wagmiConfig} siwx={siwxSession} /> <SolanaConnectorsWatcher siwx={siwxSession} /> <NovaTransactionsProvider pagination={pagination} /> <NovaConnectProvider appChains={appEVMChains} solanaRPCUrls={solanaRPCUrls} transactionPool={transactionsPool} pulsarAdapter={getAdapter()} withImpersonated withBalance withChain pagination={pagination} siwx={{ verifier: async (payload) => { const res = await fetch('/api/siwx/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); return res.ok ? res.json() : null; }, destroyer: async () => { await fetch('/api/siwx/logout', { method: 'POST' }); }, }} > {children} </NovaConnectProvider> </SatelliteConnectProvider> ); }

💻 9. Rendering UI Components (page.tsx)

Render <ConnectButton /> and <TxActionButton /> anywhere in your application:

// src/app/page.tsx 'use client'; import { ConnectButton } from '@tuwaio/sdk/nova-connect'; import { TxActionButton } from '@tuwaio/sdk/nova-transactions'; import { getAdapterFromConnectorType, OrbitAdapter } from '@tuwaio/sdk/orbit'; import { useSatelliteConnectStore } from '@tuwaio/sdk/satellite'; import { usePulsarInMemoryStore, usePulsarStore } from '@/hooks/usePulsarStore'; import { AppTxType } from '@/types'; export default function HomePage() { const executeTxAction = usePulsarStore((s) => s.executeTxAction); const getLastTxKey = usePulsarStore((s) => s.getLastTxKey); const transactionsPool = usePulsarInMemoryStore((s) => s.transactionsPool); const activeConnection = useSatelliteConnectStore((s) => s.activeConnection); const handleSwapAction = async () => { const adapterType = activeConnection?.connectorType ? getAdapterFromConnectorType(activeConnection.connectorType) : OrbitAdapter.EVM; const isEvm = adapterType === OrbitAdapter.EVM; await executeTxAction({ actionFunction: async () => { // Execute smart contract call (e.g. writeContract via Viem/Wagmi or signAndSendSolanaTx via @solana/kit) /* return await swapTokensContractCall(); */ }, onSuccess: (tx) => { console.log('Swap transaction completed successfully:', tx); }, params: { type: AppTxType.SWAP, adapter: adapterType, desiredChainID: isEvm ? 1 : 'mainnet', rpcUrl: isEvm ? undefined : activeConnection?.rpcURL, title: ['Swapping Tokens', 'Tokens Swapped', 'Error During Swap', 'Swap Transaction Replaced'], description: [ `Swapping 100 USDC for ${isEvm ? 'ETH' : 'SOL'}...`, `Success! Swapped 100 USDC for ${isEvm ? 'ETH' : 'SOL'}.`, 'Something went wrong during token swap.', 'Transaction was replaced in wallet.', ], payload: { tokenIn: 'USDC', tokenOut: isEvm ? 'ETH' : 'SOL', amount: 100, }, withTrackedModal: true, requiredConfirmations: isEvm ? 3 : undefined, }, }); }; return ( <main className="min-h-screen p-8 max-w-4xl mx-auto space-y-8"> <header className="flex items-center justify-between border-b pb-4"> <h1 className="text-2xl font-bold">TUWA Multi-Chain App</h1> <ConnectButton /> </header> <section className="p-6 bg-card rounded-xl border space-y-4"> <h2 className="text-lg font-semibold">Execute Transaction</h2> <TxActionButton action={handleSwapAction} getLastTxKey={getLastTxKey} transactionsPool={transactionsPool} walletAddress={activeConnection?.address} > Execute Swap Action </TxActionButton> </section> </main> ); }

Last updated on