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 clsx and tailwind-merge for 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 tracking
  • organizations, Org management, members, roles, billing
  • eventBus, Action execution and lifecycle events
  • wallets, Wallet creation and balance queries
  • templates, Template CRUD (the schema for each asset type)
  • objects, Object CRUD, activity history, ownership
  • faces, Visual face management for objects
  • storage, File upload/download
  • notifications, Notification preferences and delivery
  • webhooks, Webhook registration and event subscription
  • sequencer, On-chain transaction sequencing
  • indexer, 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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-sqlite3 for 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:

typescript
// /api/stats/route.ts
import { 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:

bash
# Dual SDK Configuration
NEXT_PUBLIC_DUAL_CONFIGURED=false
NEXT_PUBLIC_DUAL_API_URL=https://api-testnet.dual.network
DUAL_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:

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.