Building Three Demo Apps on Dual
A technical walkthrough of how three production-ready demo apps, ticketing, wine provenance, and real estate, were built on the Dual platform using a shared SDK and Data Provider pattern.
Live Demo Apps
Event Ticketing dual-tickets-app.vercel.app Admin Dashboard GitHub
Wine Provenance dual-wine-platform.vercel.app GitHub
Real Estate dual-real-estate-demo.vercel.app GitHub
The source code for all three demo apps is public.
Introduction
Dual is a tokenisation platform that turns real-world assets into programmable digital objects, each with an identity, ownership history, and a set of lifecycle actions recorded on-chain. To demonstrate the breadth of what Dual can tokenise, we built three production-ready demo applications, each targeting a different vertical: event ticketing, wine provenance, and real estate.
All three share the same underlying SDK and architectural patterns, but each adapts the platform's primitives (objects, templates, actions, organisations) to a domain-specific experience. This tutorial walks through the technical decisions, shared architecture, and domain-specific adaptations that went into building these apps.
The Stack
Every app uses the same core stack:
- Next.js 14 with the App Router and TypeScript
- Tailwind CSS for styling, with
clsxandtailwind-mergefor conditional class composition - Lucide React for iconography
- html5-qrcode for camera-based QR scanning (each app includes a verification flow)
- Vercel for deployment, with automatic builds triggered on push to
main
The real estate app additionally uses better-sqlite3 as a local persistence layer for minted properties, giving it offline-capable local state that syncs with the Dual API when connected.
The Dual SDK
At the centre of all three apps is the Dual SDK, a TypeScript client library with 102 methods organised across 14 modules:
payments, Payment configuration and deposit trackingorganizations, Org management, members, roles, billingeventBus, Action execution and lifecycle eventswallets, Wallet creation and balance queriestemplates, Template CRUD (the schema for each asset type)objects, Object CRUD, activity history, ownershipfaces, Visual face management for objectsstorage, File upload/downloadnotifications, Notification preferences and deliverywebhooks, Webhook registration and event subscriptionsequencer, On-chain transaction sequencingindexer, Public stats, search, and indexing
The SDK is zero-dependency and wraps an HttpClient class that handles bearer token auth, automatic retry with exponential backoff, configurable timeouts, and AbortController-based cancellation. It connects to the Dual v3 API at https://api-testnet.dual.network by default, but the base URL is environment-configurable.
Rather than installing the SDK from npm, we embedded the full TypeScript source directly into each app as src/lib/dual-sdk.ts. This keeps the apps self-contained and avoids a build-time dependency on an external registry.
The Data Provider Pattern
The most important architectural decision across all three apps is the Data Provider pattern. Each app defines a DataProvider interface that abstracts all data access behind a common contract, then provides two implementations:
- DemoDataProvider, returns hardcoded sample data, requires no credentials
- DualDataProvider, calls the live Dual API through the SDK
A factory function checks the runtime environment and returns the appropriate implementation:
export function getDataProvider(): DataProvider {if (isDualConfigured()) {return new DualDataProvider();}return new DemoDataProvider();}
The isDualConfigured() Guard
Configuration is controlled by two environment variables. Both must be present for the live provider to activate:
export function isDualConfigured(): boolean {return process.env.NEXT_PUBLIC_DUAL_CONFIGURED === 'true'&& !!process.env.DUAL_API_TOKEN;}
NEXT_PUBLIC_DUAL_CONFIGURED is a public environment variable (available client-side in Next.js), making it possible for the frontend to show "Demo Mode" indicators. DUAL_API_TOKEN is server-only (no NEXT_PUBLIC_ prefix), so it never leaks to the browser.
The Singleton Client
A module-scoped singleton avoids creating a new HTTP client on every API route invocation:
let client: DualClient | null = null;export function getDualClient(): DualClient {if (!client) {client = new DualClient({token: process.env.DUAL_API_TOKEN || '',baseUrl: process.env.NEXT_PUBLIC_DUAL_API_URL || 'https://api-testnet.dual.network',timeout: 30000,retry: { maxAttempts: 3, backoffMs: 1000 },});}return client;}
App 1: Tokenised Event Ticketing
The tickets app models events and experiences, concerts, sports finals, yoga sessions, helicopter tours, as Dual-tokenised objects. Each ticket is a digital object with a unique identity, ownership, and a QR code that can be scanned for verification.
Live: dual-tickets-app.vercel.app
Domain Mapping
- Template → Event type (concert, sport, experience)
- Object → Individual ticket
- Action → Scan, transfer, redeem
- Indexer stats → Platform-wide ticket analytics
Architecture
The app uses Next.js route groups for layout separation: /wallet for the consumer-facing ticket wallet, discovery, and transfers, and /admin for event management, minting, scanning, and webhooks.
The DataProvider interface is lean:
export interface DataProvider {listTickets(): Promise;getTicket(id: string): Promise;listEvents(): Promise;getEvent(id: string): Promise;getStats(): Promise;execute(objectId: string, actionType: string, payload?: any): Promise;}
The demo data includes five Sydney-based events with realistic pricing (AUD $85–$650). The DualDataProvider maps tickets to client.objects.list() and events to client.templates.list().
Key Features
- Camera-based QR scanner for venue entry verification
- Real-time SSE endpoint for live ticket status updates
- Admin webhook management for Dual event subscription
- Ticket marketplace with discovery and filtering
App 2: Wine Provenance Platform
The wine app demonstrates supply-chain tokenisation, each bottle is a Dual object whose provenance (vineyard, vintage, cellar conditions, ownership transfers) is recorded as an immutable chain of actions.
Live: dual-wine-platform.vercel.app
Domain Mapping
- Template → Wine token schema (varietal, region, vintage)
- Object → Individual bottle token
- Action → Mint, list, purchase, transfer, verify, redeem, burn
- Organisation → Wine vault / trading house
Architecture
The wine app has the richest data provider, with a full state machine for wine lifecycle transitions:
const VALID_TRANSITIONS: Record = {draft: ["minted"],minted: ["anchoring", "listed"],anchoring: ["anchored", "draft"],anchored: ["listed", "burned"],listed: ["sold", "anchored"],sold: ["transferred", "redeemed"],transferred: ["listed", "anchored"],redeemed: [],burned: [],};
This means the demo provider enforces the same business rules as the live platform. You can't redeem a bottle that hasn't been sold, and you can't burn one that's already been redeemed.
The app separates consumer and admin experiences with distinct route groups: /(consumer)/wallet for portfolio, browse, scan, and activity, and /(admin)/admin for the dashboard, inventory, minting, templates, and organisations.
Key Features
- Full lifecycle state machine with valid transition enforcement
- In-memory store for demo mode mutation
- 12 pre-loaded wines across Australian, French, Italian, and American regions
- Provenance chain with transaction hashes and actor attribution
- Mobile-first consumer wallet with dark wine-themed design
App 3: Real Estate Tokenisation
The real estate app tokenises property assets, apartments, houses, commercial spaces, with on-chain anchoring status, ownership wallets, and a full action history.
Live: dual-real-estate-demo.vercel.app
Domain Mapping
- Template → Property type schema
- Object → Individual property token
- Action → Mint, anchor, list, transfer, verify
- Organisation → Property management firm
- Face → Property images and documents
Architecture
This app introduced a unique challenge: it was originally built with a different API shape. To integrate the SDK without rewriting the entire data provider layer, we created a backward-compatible facade:
export const dualClient = {isConfigured: isDualConfigured,listProperties: async (filters?) => {const c = getDualClient();const result = await c.objects.list(filters);return result?.objects || result?.data || result || [];},mintProperty: async (templateId, ownerWallet, propertyData) => {const c = getDualClient();return c.eventBus.execute({actionType: 'MINT', templateId, ownerWallet, data: propertyData});},};
This preserves the existing import contract while routing all calls through the official SDK underneath. The app also features a dual fallback chain: Dual API → SQLite DB → Demo data.
Key Features
- SQLite local persistence via
better-sqlite3for offline-capable minting - Dual fallback chain: Dual API → SQLite DB → Demo data
- Property filtering by type, city, and status with pagination
- Face uploader for property images
- Custom React hooks (
useDataProvider,useActionStatus) for clean component integration
Shared Patterns
API Route Layer
Each app exposes Next.js API routes that call getDataProvider() and return JSON. Components never import the SDK directly:
// /api/stats/route.tsimport { getDataProvider } from '@/lib/data-provider';export async function GET() {const provider = getDataProvider();const stats = await provider.getStats();return Response.json(stats);}
QR Scanning
All three apps include camera-based QR code scanning via html5-qrcode. The flow is consistent: scan a code, decode the token/object ID, fetch its status from the data provider, and display a verification result with provenance details.
Real-Time Updates
Each app implements a Server-Sent Events (SSE) endpoint at /api/sse for pushing live updates to connected clients, real-time dashboards, live ticket scanning status, and action completion notifications.
Webhook Integration
Every app includes an admin webhook management page and a /api/webhooks endpoint for receiving Dual platform events. When connected to the live API, these webhooks fire on object state changes, action completions, and organisation events.
Environment Configuration
All apps share the same .env.example pattern:
# Dual SDK ConfigurationNEXT_PUBLIC_DUAL_CONFIGURED=falseNEXT_PUBLIC_DUAL_API_URL=https://api-testnet.dual.networkDUAL_API_TOKEN=DUAL_ORG_ID=DUAL_TEMPLATE_ID=
Setting NEXT_PUBLIC_DUAL_CONFIGURED=true and providing a DUAL_API_TOKEN is all it takes to switch from demo data to live platform data. No code changes, no rebuilds, just environment variables.
Deployment
All three apps deploy to Vercel with zero configuration. Each repository is connected to a Vercel project, and every push to main triggers an automatic build and deployment:
- Tickets: dual-tickets-app.vercel.app
- Real Estate: dual-real-estate-demo.vercel.app
Key Takeaways
- Data Provider Pattern: Abstracting data access behind an interface with demo and live implementations lets you ship a working demo instantly and switch to production data with environment variables alone
- Embedded SDK: Bundling the SDK source directly avoids npm registry dependencies, keeping demos fully self-contained
- Domain Flexibility: The same Dual primitives (objects, templates, actions, organisations) map naturally to wildly different verticals, tickets, wine bottles, and real estate properties
- Backward Compatibility: When integrating an SDK into an existing codebase, a facade object that maps old method names to new SDK calls avoids a full rewrite
- Consistent Architecture: Sharing the same patterns (API routes, SSE, QR scanning, webhooks) across all apps means anyone familiar with one can immediately navigate the others
Try It Yourself: Clone any of the three repositories, run npm install && npm run dev, and you'll have a working tokenisation demo in seconds, no API credentials required. When you're ready to connect to the live Dual platform, just set the environment variables and redeploy.