SIGIL · INTEGRATION DOCS← Back to site
[ GUIDES ]

React integration

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.

[ HOW IT WORKS ]

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.

Install

TERMINAL
npm install @sigil-oss/connect

useSigil hook

A 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
// 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 };
}

Callback route

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
// 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;
}

React Router v6 / v7

App.tsx
// 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} />;
}

Next.js — App Router

app/__sigil__/page.tsx
// 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;
}

Next.js — Pages Router

pages/__sigil__.tsx
// 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;
}

Connect wallet

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
// 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>
  );
}

Sign in with Qubic

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
// 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>}
    </>
  );
}
api/auth/qubic.ts (server)
// 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 });
}
[ VERIFY SERVER-SIDE ]

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.

Request a transfer

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
// 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>
  );
}