UI Integration#
This section covers building the verification user interface, including configuration, backend services, React hooks, and UI components.
Install SDK#
npm install @concordium/verification-web-ui
Create Configuration#
Purpose: This file holds all the configuration constants your app needs. Think of it as a central settings file that other parts of your app can reference.
Create src/lib/config.ts:
/**
* TRUSTED_ISSUERS: The official identity providers that Concordium trusts.
* When someone creates a Concordium ID, they do it through one of these
* official providers. We only accept proofs from these trusted sources.
*
* Each "did:ccd:testnet:idp:X" is like a trusted government passport office.
* The "did" stands for "Decentralized Identifier" - a blockchain address.
*/
export const TRUSTED_ISSUERS = {
testnet: [
"did:ccd:testnet:idp:0", // Testnet identity provider 0
"did:ccd:testnet:idp:1", // Testnet identity provider 1
"did:ccd:testnet:idp:2", // Testnet identity provider 2
"did:ccd:testnet:idp:3", // Testnet identity provider 3
],
mainnet: [
"did:ccd:mainnet:idp:0", // Mainnet identity provider 0
"did:ccd:mainnet:idp:1", // Mainnet identity provider 1
"did:ccd:mainnet:idp:2", // Mainnet identity provider 2
"did:ccd:mainnet:idp:3", // Mainnet identity provider 3
],
} as const;
// Type definition: tells TypeScript we only accept "testnet" or "mainnet" as values
export type ConcordiumNetwork = "testnet" | "mainnet";
/**
* NETWORK: Which blockchain network are we using?
* - testnet: For development and testing (fake money, test identities)
* - mainnet: For production (real money, real identities)
*
* This reads from your .env.local file, defaults to "testnet" if not set.
*/
export const NETWORK = (process.env.NEXT_PUBLIC_CONCORDIUM_NETWORK ||
"testnet") as ConcordiumNetwork;
/**
* ISSUERS: Gets the list of trusted identity providers for our current network.
* If we're on testnet, this will be the testnet issuers.
* If we're on mainnet, this will be the mainnet issuers.
*/
export const ISSUERS = [...TRUSTED_ISSUERS[NETWORK]];
/**
* REQUIRED_AGE: The minimum age users must be.
* Default is 18, but you can change this in .env.local
*/
export const REQUIRED_AGE = parseInt(process.env.REQUIRED_AGE || "18", 10);
/**
* VERIFIER_SERVICE_URL: Where is our Docker verifier service running?
* Default is localhost:8000 (the service we started earlier)
*/
export const VERIFIER_SERVICE_URL =
process.env.VERIFIER_SERVICE_URL || "http://localhost:8000";
/**
* getDobUpperBound(): Calculates the latest birthdate that qualifies as "old enough"
*
* How it works:
* 1. Get today's date
* 2. Subtract REQUIRED_AGE years from it
* 3. Anyone born ON or BEFORE that date is at least REQUIRED_AGE years old
*
* Example: If today is Feb 17, 2026 and REQUIRED_AGE is 18:
* - Cutoff date = Feb 17, 2008
* - Returns: "20080217" (YYYYMMDD format)
* - Anyone born before/on Feb 17, 2008 is 18+ years old
*/
export function getDobUpperBound(): string {
const now = new Date(); // Today's date
// Create a date that's REQUIRED_AGE years ago
const cutoff = new Date(
now.getFullYear() - REQUIRED_AGE, // Subtract years
now.getMonth(), // Keep same month
now.getDate(), // Keep same day
);
// Format the date as YYYYMMDD string (e.g., "20080217")
const year = cutoff.getFullYear().toString();
const month = (cutoff.getMonth() + 1).toString().padStart(2, "0"); // +1 because months are 0-indexed
const day = cutoff.getDate().toString().padStart(2, "0");
return `${year}${month}${day}`;
}
What this file does:
Defines which identity providers we trust
Sets which blockchain network to use (testnet/mainnet)
Configures the minimum age requirement
Calculates date boundaries for age verification
Create Verifier Service Client#
Purpose: This file is your communication bridge to the Docker verifier service. It has two main jobs:
Create proof requests - “Hey user, please prove you’re 18+”
Verify proofs - “Let me check if this proof is valid”
Create src/lib/verifier-service.ts:
// Import our configuration settings from the previous step
import { VERIFIER_SERVICE_URL, ISSUERS, getDobUpperBound } from "./config";
/**
* createVerificationRequest()
*
* This function asks the verifier service to create a "proof request" (VPR).
* Think of it like creating a form that says "Please prove you're 18+ without
* telling me your exact birthdate."
*
* The verifier service will:
* 1. Create the proof request
* 2. Record it on the blockchain (for audit purposes)
* 3. Return the request so we can send it to the user's wallet
*/
export async function createVerificationRequest(params: {
connectionId: string; // WalletConnect session ID (links to user's wallet)
resourceId: string; // What are they trying to access? (e.g., "/news")
contextString: string; // Human-readable description (e.g., "Age verification")
}) {
// Calculate the cutoff date for age verification
// Example: If we need 18+, this returns "20080217" (Feb 17, 2008)
const dobUpperBound = getDobUpperBound();
/**
* Build the request body that we'll send to the verifier service.
* This is the "form" we're asking the user to fill out.
*/
const body = {
// connectionId: Links this request to the user's WalletConnect session
connectionId: params.connectionId,
// resourceId: What resource needs age verification?
resourceId: params.resourceId,
// contextString: Human-readable explanation shown to the user
contextString: params.contextString,
// public_info: Extra metadata (shown in the proof, public on blockchain)
public_info: { merchant: "my app" },
/**
* requestedClaims: This is the MOST IMPORTANT part.
* It defines EXACTLY what we're asking the user to prove.
*/
requestedClaims: [
{
// type: We want identity claims (from their Concordium ID)
type: "identity",
// source: Where should the proof come from? Their ID credential.
source: ["identityCredential"],
/**
* issuers: We only accept proofs from these trusted identity providers.
* This prevents fake IDs - only official Concordium identity providers
* can issue valid credentials.
*/
issuers: ISSUERS,
/**
* statements: The actual requirements we're checking.
* This is where we define "prove you're 18+"
*/
statements: [
{
/**
* type: "AttributeInRange" means "prove this value is within a range"
* This is ZERO-KNOWLEDGE - the user proves age WITHOUT revealing DOB
*/
type: "AttributeInRange",
// attributeTag: Which attribute? "dob" = date of birth
attributeTag: "dob",
/**
* lower: Minimum birthdate (Jan 1, 1900)
* This prevents errors for very old users
*/
lower: "19000101",
/**
* upper: Maximum birthdate (the cutoff we calculated)
* Example: "20080217" means born on/before Feb 17, 2008
* Anyone with dob <= 20080217 is at least 18 years old today
*/
upper: dobUpperBound,
},
],
},
],
};
/**
* Send the request to our verifier service (running in Docker).
* The service will create the VPR and anchor it on the blockchain.
*/
const response = await fetch(
`${VERIFIER_SERVICE_URL}/verifiable-presentations/create-verification-request`,
{
method: "POST", // POST request to create a new verification
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), // Convert our request to JSON
},
);
// If something went wrong, throw an error with details
if (!response.ok) {
const text = await response.text();
throw new Error(`Verifier Service failed: ${response.status} ${text}`);
}
/**
* Success! Return the VPR (Verifiable Presentation Request).
* This contains:
* - The proof request to send to the user's wallet
* - An audit record ID (for tracking)
* - A blockchain transaction hash (proof this request was recorded)
*/
return response.json();
}
/**
* verifyPresentation()
*
* This function checks if a proof submitted by the user is valid.
* Think of it like checking a signed document - we verify:
* 1. The signature is real
* 2. It was signed by a trusted authority
* 3. The content matches what we asked for
*
* The verifier service will:
* 1. Validate the cryptographic proof
* 2. Check it matches our original request
* 3. Record the verification result on the blockchain
* 4. Return "verified" or "failed"
*/
export async function verifyPresentation(params: {
auditRecordId: string; // The ID from when we created the request
presentation: unknown; // The proof submitted by the user
verificationRequest: unknown; // The original request we made (VPR)
}) {
/**
* The wallet might wrap the proof in an extra layer.
* We need to unwrap it to get the actual proof.
*
* Example input: { verifiablePresentationJson: { actual proof } }
* We extract: { actual proof }
*/
const rawPresentation = params.presentation as Record<string, unknown>;
const presentation =
rawPresentation.verifiablePresentationJson ?? rawPresentation;
/**
* Build the verification request body.
* We send:
* 1. auditRecordId - Links to the original request
* 2. presentation - The proof to verify
* 3. verificationRequest - The original VPR (so service knows what to check)
*/
const body = {
auditRecordId: params.auditRecordId,
presentation,
verificationRequest: params.verificationRequest,
};
/**
* Send to verifier service for validation.
* The service will:
* - Verify the cryptographic proof
* - Check the age range statement
* - Anchor the verification result on blockchain
*/
const response = await fetch(
`${VERIFIER_SERVICE_URL}/verifiable-presentations/verify`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
// If verification failed, throw an error
if (!response.ok) {
const text = await response.text();
throw new Error(`Verification failed: ${response.status} ${text}`);
}
/**
* Success! Return the verification result.
* This contains:
* - result: "verified" or "failed"
* - anchorTransactionHash: Blockchain proof of this verification
* - The verified claims (but NOT the user's actual DOB!)
*/
return response.json();
}
What this file does:
Creates proof requests (“prove you’re 18+”)
Sends requests to Docker verifier service
Verifies proofs submitted by users
Handles all communication with the blockchain (via verifier service)
Key Concepts:
VPR (Verifiable Presentation Request): “Please prove X about yourself”
VP (Verifiable Presentation): “Here’s my proof of X” (the user’s response)
Zero-knowledge: User proves age without revealing birthdate
Blockchain anchoring: Every request and verification is recorded on-chain for auditing
Create Verification Hook#
Purpose: This is the “brain” of your verification UI. It’s a React hook that:
Manages the verification workflow (connecting → verifying → verified)
Handles communication between your app, the SDK, and the user’s wallet
Listens for events from the SDK (like “user scanned QR code” or “proof received”)
Think of it as an orchestrator that coordinates all the pieces.
Create src/hooks/useVerification.ts:
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { NETWORK } from "@/lib/config";
/**
* getSDK() - Dynamic Import Function
*
* Why do we use `await import()` instead of `import` at the top?
*
* 1. **SSR Compatibility**: The SDK uses browser APIs (window, localStorage, DOM)
* that don't exist during Server-Side Rendering. Next.js tries to render
* components on the server first, which would crash if we import the SDK normally.
*
* 2. **Lazy Loading**: The SDK is only loaded when the user clicks "Verify Age".
* This keeps your initial page load fast and small.
*
* 3. **Code Splitting**: Next.js automatically creates a separate chunk for the SDK,
* so users who never verify don't download it at all.
*
* Think of it like: "Only load the SDK when we're in the browser AND when we need it."
*/
async function getSDK() {
const { ConcordiumVerificationWebUI, resetSDK } =
await import("@concordium/verification-web-ui");
return { ConcordiumVerificationWebUI, resetSDK };
}
export function useVerification() {
/**
* State Management (useState):
* - Tracks the current verification flow step
* - Causes re-renders when state changes (updates UI automatically)
*/
const [state, setState] = useState<
"idle" | "connecting" | "verifying" | "verified" | "failed"
>("idle");
const [error, setError] = useState<string | null>(null);
/**
* Refs (useRef):
* - Store values that persist between renders WITHOUT causing re-renders
* - Like "memory" that survives when React updates the UI
*
* sdkRef: Holds the SDK instance so we can call methods like closeModal()
* sessionIdRef: Remembers the audit record ID from /api/verification/create
* vprRef: Stores the Verifiable Presentation Request to send to /verify later
*/
const sdkRef = useRef<any>(null);
const sessionIdRef = useRef<string | null>(null);
const vprRef = useRef<unknown>(null);
/**
* useEffect: Runs ONCE when component mounts (because of empty [] dependency)
* Sets up the event listener to receive events from the SDK.
*
* The SDK emits CustomEvents that we listen to here. This is like a
* messaging system where the SDK says "something happened!" and we respond.
*/
useEffect(() => {
const handleSDKEvent = async (event: Event) => {
// Extract the event type and data from the SDK's CustomEvent
const { type, data } = (event as CustomEvent).detail;
/**
* Event Flow (what happens in order):
*
* 1. User scans QR code with wallet
* 2. SDK emits "session_approved" → we create VPR and send to wallet
* 3. Wallet creates proof and sends back
* 4. SDK emits "presentation_received" → we verify the proof
* 5. If successful, we show "verified" state
*/
switch (type) {
case "session_approved": {
// User successfully connected their wallet via WalletConnect
const { topic } = data; // WalletConnect session ID
setState("verifying");
try {
/**
* Step 1: Call our API to create a Verifiable Presentation Request (VPR)
* This also anchors the request on-chain via the Verifier Service.
* The "topic" is the WalletConnect session that links to the user's wallet.
*/
const createRes = await fetch("/api/verification/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId: topic }),
});
if (!createRes.ok) throw new Error("Failed to create VPR");
const { sessionId, vpr } = await createRes.json();
// Save these for later use in the "presentation_received" event
sessionIdRef.current = sessionId; // Audit record ID (for /verify endpoint)
vprRef.current = vpr; // The VPR (also needed for /verify)
/**
* Step 2: Send the VPR to the user's wallet
* The wallet will show the user what we're asking for ("Prove you're 18+")
* and create a zero-knowledge proof if they agree.
*/
await sdkRef.current!.sendPresentationRequest(vpr, topic);
} catch (err: any) {
setError(err.message);
setState("failed");
}
break;
}
case "presentation_received": {
/**
* The wallet created a proof and sent it back!
* Now we need to verify it using the Verifier Service.
*
* The "data" here is the Verifiable Presentation (VP) - the actual proof.
*/
try {
const verifyRes = await fetch("/api/verification/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: sessionIdRef.current, // Links to our audit record
presentation: data, // The proof from the wallet
verificationRequest: vprRef.current, // The original VPR we sent
}),
});
if (!verifyRes.ok) throw new Error("Verification failed");
// Success! The user is verified as 18+
setState("verified");
// Close the modal after 2 seconds to let user see success message
setTimeout(() => sdkRef.current?.closeModal(), 2000);
} catch (err: any) {
setError(err.message);
setState("failed");
}
break;
}
case "error": {
// SDK encountered an error (network issue, user rejected, etc.)
setError(data?.message || "An error occurred");
setState("failed");
break;
}
}
};
// Register the event listener
window.addEventListener("verification-web-ui-event", handleSDKEvent);
// Cleanup function: remove listener when component unmounts
// (Prevents memory leaks and duplicate listeners)
return () =>
window.removeEventListener("verification-web-ui-event", handleSDKEvent);
}, []);
/**
* startVerification() - Called when user clicks "Verify Age" button
*
* useCallback: Memoizes the function so it doesn't get recreated on every render.
* This prevents unnecessary re-renders if this function is passed to child components.
*/
const startVerification = useCallback(async () => {
try {
setState("connecting");
setError(null);
// Dynamically import the SDK (only runs in browser, not during SSR)
const { ConcordiumVerificationWebUI, resetSDK } = await getSDK();
// Reset SDK state to clear any previous sessions
// (Important if user verifies multiple times without refreshing)
resetSDK();
/**
* Initialize the SDK with:
* - network: Which Concordium network (testnet/mainnet)
* - projectId: Your WalletConnect project ID from cloud.walletconnect.com
* - metadata: Information shown in the wallet when connecting
*/
const sdk = new ConcordiumVerificationWebUI({
network: NETWORK,
projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
metadata: {
name: "My Age Verification App",
description: "Privacy-preserving age verification",
url: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
icons: [
`${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/logo.svg`,
],
},
});
// Save SDK instance to ref so we can use it in event handlers
sdkRef.current = sdk;
/**
* renderUIModals(): Shows the QR code modal
* - The callback runs when the modal closes
* - We reset to "idle" if user closed modal without completing verification
*/
await sdk.renderUIModals(() => {
if (state !== "verified") setState("idle");
});
} catch (err: any) {
setError(err.message);
setState("failed");
}
}, []);
/**
* Return values used by the UI component:
* - state: Current step in the flow (idle/connecting/verifying/verified/failed)
* - error: Error message if something went wrong
* - startVerification: Function to start the verification process
*/
return { state, error, startVerification };
}
Import SDK Styles#
In src/app/layout.tsx, add:
import "@concordium/verification-web-ui/styles";
Create API Routes#
Purpose: These are your backend endpoints that the frontend calls. They act as a secure layer between your frontend and the verifier service.
Why use API routes?
Keeps your backend logic server-side (more secure)
Hides verifier service details from the browser
Can add authentication, rate limiting, etc.
Create src/app/api/verification/create/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { createVerificationRequest } from "@/lib/verifier-service";
// POST /api/verification/create
// Called when: User scans QR code and WalletConnect session is established
// Purpose: Create a proof request (VPR) and return it to the frontend
export async function POST(request: NextRequest) {
try {
// Extract the WalletConnect session ID from the request
const { connectionId } = await request.json();
// Call the verifier service to create a VPR
const result = await createVerificationRequest({
connectionId, // Links to user's wallet
resourceId: "/protected-content", // What they're accessing
contextString: "Age verification", // Shown to user
});
// Return the session ID and VPR to the frontend
// Frontend will send the VPR to the user's wallet
return NextResponse.json({
sessionId: result.id, // Audit record ID (for tracking)
vpr: result.request, // The proof request to send to wallet
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
Create src/app/api/verification/verify/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { verifyPresentation } from "@/lib/verifier-service";
// POST /api/verification/verify
// Called when: User approves proof request and wallet sends back the proof
// Purpose: Verify the proof is valid and return the result
export async function POST(request: NextRequest) {
try {
// Extract the proof data from the request
const { sessionId, presentation, verificationRequest } =
await request.json();
// Call the verifier service to validate the proof
const result = await verifyPresentation({
auditRecordId: sessionId, // Links to original request
presentation, // The proof from wallet
verificationRequest, // Original VPR (for validation)
});
// Check if verification succeeded
if (result.result !== "verified") {
throw new Error("Verification failed");
}
// Return success with blockchain transaction hash
return NextResponse.json({
verified: true,
anchorTransactionHash:
result.verificationAuditRecord.anchorTransactionHash, // Proof it's on-chain
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
What these routes do:
/create- Creates proof requests when wallet connects/verify- Validates proofs submitted by usersBoth routes are server-side (secure, can’t be bypassed by users)
Create UI Component#
Purpose: This component shows a blocking overlay until the user verifies their age. It uses the useVerification hook we created earlier.
Create src/components/AgeGate.tsx:
"use client";
import { useVerification } from "@/hooks/useVerification";
export function AgeGate() {
// Get verification state and functions from our hook
const { state, error, startVerification } = useVerification();
// If user is verified, hide the gate (return nothing)
if (state === "verified") {
return null;
}
// Show a modal overlay blocking the content
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 max-w-md">
<h2 className="text-2xl font-bold mb-4">Age Verification Required</h2>
<p className="text-gray-600 mb-6">
You must be 18 or older to access this content.
We will verify your age using zero-knowledge proofs - your birthdate stays private.
</p>
{/* Main verification button - calls startVerification() when clicked */}
<button
onClick={startVerification}
disabled={state === "connecting" || state === "verifying"}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
>
{/* Show different text based on current state */}
{state === "connecting" && "Connecting..."}
{state === "verifying" && "Verifying..."}
{state === "idle" && "Verify Age with Concordium ID"}
{state === "failed" && "Retry Verification"}
</button>
{/* Show error message if something went wrong */}
{error && (
<p className="text-red-600 text-sm mt-4">{error}</p>
)}
</div>
</div>
);
}
What this component does:
Blocks content until user is verified
Shows “Verify Age” button
Updates button text based on state (idle/connecting/verifying)
Displays errors if verification fails
Disappears when verification succeeds
Use in Your Page#
In src/app/page.tsx:
import { AgeGate } from "@/components/AgeGate";
export default function Home() {
return (
<>
<AgeGate />
<main className="p-8">
<h1>Protected Content</h1>
<p>This content is only accessible to verified users 18+</p>
</main>
</>
);
}
Checkpoint: UI integrated, verification flow ready