This guide covers everything needed to integrate Sigil into a React app: a reusable hook, the callback route that closes the async loop, and ready-to-use components for the most common flows.
sigilRequest() opens Sigil via a link click and returns a Promise backed by BroadcastChannel. The page at your callbackPath (default /__sigil__) calls handleRedirect() — it reads the ?result= param and resolves the Promise. Both routes must be on the same origin.
npm install @sigil-oss/connectA thin wrapper around sigilRequest() that exposes status, result, and error as React state. Drop it in your hooks/ folder and reuse across components.
// hooks/useSigil.ts
import { useCallback, useRef, useState } from 'react';
import {
sigilRequest,
type SigilRequest,
type SigilCallbackResponse,
} from '@sigil-oss/connect';
type Status = 'idle' | 'pending' | 'success' | 'error';
export function useSigil() {
const [status, setStatus] = useState<Status>('idle');
const [result, setResult] = useState<SigilCallbackResponse | null>(null);
const [error, setError] = useState<Error | null>(null);
const pendingRef = useRef(false);
const request = useCallback(async (req: SigilRequest) => {
if (pendingRef.current) throw new Error('A Sigil request is already in progress');
pendingRef.current = true;
setStatus('pending');
setResult(null);
setError(null);
try {
const res = await sigilRequest(req);
setResult(res);
setStatus('success');
return res;
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
setStatus('error');
throw e;
} finally {
pendingRef.current = false;
}
}, []);
const reset = useCallback(() => {
setStatus('idle');
setResult(null);
setError(null);
}, []);
return { request, status, result, error, reset };
}Create a minimal page at /__sigil__ (or whatever callbackPath you pass to sigilRequest). It calls handleRedirect() on mount, broadcasts the result, and the tab closes itself.
// pages/__sigil__.tsx (or any path — just match callbackPath below)
import { useEffect } from 'react';
import { handleRedirect } from '@sigil-oss/connect';
export function SigilCallbackPage() {
useEffect(() => {
handleRedirect(); // reads ?result=, broadcasts to useSigil, closes tab
}, []);
return null;
}// App.tsx — React Router v6 / v7
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { SigilCallbackPage } from './pages/__sigil__';
import { Home } from './pages/Home';
const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/__sigil__', element: <SigilCallbackPage /> },
// … rest of your routes
]);
export function App() {
return <RouterProvider router={router} />;
}// app/__sigil__/page.tsx — Next.js App Router
'use client';
import { useEffect } from 'react';
import { handleRedirect } from '@sigil-oss/connect';
export default function SigilCallbackPage() {
useEffect(() => {
handleRedirect();
}, []);
return null;
}// pages/__sigil__.tsx — Next.js Pages Router
import { useEffect } from 'react';
import { handleRedirect } from '@sigil-oss/connect';
export default function SigilCallbackPage() {
useEffect(() => {
handleRedirect();
}, []);
return null;
}Ask the user to pair their wallet and optionally pre-grant permissions. The result includes the Qubic identity and the approved permission set.
// components/ConnectButton.tsx
import { createConnectRequest } from '@sigil-oss/connect';
import { useSigil } from '../hooks/useSigil';
export function ConnectButton() {
const { request, status, result, reset } = useSigil();
async function connect() {
const res = await request(
createConnectRequest({
type: 'connect',
dapp: { name: 'My App', origin: 'https://myapp.example' },
permissions: ['transfer', 'sign_message'],
})
);
if (res.status === 'connected') {
// persist res.identity and res.permissions in your app state
console.log('identity:', res.identity);
console.log('permissions:', res.permissions);
}
}
if (status === 'success' && result?.status === 'connected') {
return (
<div>
<p>Connected: {result.identity.slice(0, 8)}…</p>
<button onClick={reset}>Disconnect</button>
</div>
);
}
return (
<button onClick={connect} disabled={status === 'pending'}>
{status === 'pending' ? 'Opening Sigil…' : 'Connect Wallet'}
</button>
);
}Off-chain authentication — no transaction, no fee. The user signs a message that includes a nonce and timestamp; your server verifies the signature against the public key to prove identity ownership.
// components/SignInButton.tsx
import { createSignMessageRequest } from '@sigil-oss/connect';
import { useSigil } from '../hooks/useSigil';
interface Props {
onSignIn: (identity: string) => void;
}
export function SignInButton({ onSignIn }: Props) {
const { request, status, error } = useSigil();
async function signIn() {
const nonce = crypto.randomUUID();
const res = await request(
createSignMessageRequest({
type: 'sign_message',
dapp: { name: 'My App', origin: 'https://myapp.example' },
// Include nonce and timestamp so the server can prevent replays
message: [
'Sign in to My App',
`nonce: ${nonce}`,
`issuedAt: ${new Date().toISOString()}`,
].join('\n'),
})
);
if (res.status !== 'signed' || res.type !== 'sign_message') return;
// Send to your server for verification
const response = await fetch('/api/auth/qubic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identity: res.identity,
signature: res.signature,
public_key: res.public_key,
nonce,
}),
});
if (response.ok) onSignIn(res.identity);
}
return (
<>
<button onClick={signIn} disabled={status === 'pending'}>
{status === 'pending' ? 'Waiting for Sigil…' : 'Sign in with Qubic'}
</button>
{error && <p style={{ color: 'red' }}>{error.message}</p>}
</>
);
}// server: api/auth/qubic.ts (Next.js API route or Express)
// Verify a sign_message callback from the client
export async function POST(req: Request) {
const { identity, signature, public_key, nonce } = await req.json();
// 1. Verify the nonce hasn't been used (store in Redis/DB with TTL)
const nonceKey = `sigil:nonce:${nonce}`;
const used = await redis.get(nonceKey);
if (used) return new Response('Nonce already used', { status: 400 });
await redis.set(nonceKey, '1', { ex: 300 });
// 2. Reconstruct the message the user signed
// You need the nonce and issuedAt from the request — store them server-side
// before the redirect, or pass them back from the client.
// 3. Verify the signature with the Qubic public key
// Use the Qubic crypto library on the server to verify.
// 4. Issue a session token
const token = await createSession(identity);
return Response.json({ token });
}Never trust the identity the client sends without verifying the signature on your server. The client-side result is not authenticated — only the signature proves the user holds the corresponding private key.
Ask the user to sign and broadcast a QU transfer. The callback includes the transaction hash and target tick once Sigil submits it to the network.
// components/TransferButton.tsx
import { createTransferRequest } from '@sigil-oss/connect';
import { useSigil } from '../hooks/useSigil';
interface Props {
to: string;
amount: number;
onSent?: (txHash: string) => void;
}
export function TransferButton({ to, amount, onSent }: Props) {
const { request, status, result } = useSigil();
async function send() {
const res = await request(
createTransferRequest({
type: 'transfer',
dapp: { name: 'My App', origin: 'https://myapp.example' },
to,
amount,
})
);
if (res.status === 'signed' && (res.type === 'transfer' || res.type === 'sc_call')) {
onSent?.(res.tx_hash);
}
}
if (
status === 'success' &&
result?.status === 'signed' &&
(result.type === 'transfer' || result.type === 'sc_call')
) {
return <p>Sent — tx: {result.tx_hash.slice(0, 12)}…</p>;
}
return (
<button onClick={send} disabled={status === 'pending'}>
{status === 'pending' ? 'Waiting for Sigil…' : `Send ${amount.toLocaleString()} QU`}
</button>
);
}