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:
Option A: Complete Bundle Import (Recommended)
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
signingSecretinstead ofsessionStore: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>
);
}🔗 TUWA Ecosystem Quick Links
- 🌐 TUWA Documentation Hub — Central directory linking to all ecosystem documentation sites.
- 💫 Orbit Utils Docs — Low-level, framework-agnostic multi-chain communication primitives.
- 🔐 SIWX Core Docs — Headless CAIP-122 multi-chain authentication standard.
- 📡 Satellite Connect Docs — Headless wallet state machine.
- ⚡ Pulsar Engine Docs — Headless transaction lifecycle & tracking engine.
- 🎨 Nova UI Storybook — Interactive component catalog and visual design system.