Capxul Docs
Getting started

Quickstart

Mount Capxul in Next.js and drive the canonical email-OTP identity journey.

Your application owns pages, forms, slots, navigation, and caller IDs. CapxulProvider owns the SDK client and React cache. The backend owns publishable-key validation, OTP, sessions, identity, and account setup.

Install and configure

Install the published packages with your package manager, then expose the public key to the browser:

NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY=cap_pk_test_...

Proxy bootstrap and auth through the application origin. See CapxulProvider for the current Next.js rewrite contract.

Mount the provider

"use client";

import { CapxulProvider } from "@capxul/sdk-react";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <CapxulProvider
      publishableKey={process.env.NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY!}
      requirement="deployed"
    >
      {children}
    </CapxulProvider>
  );
}

Send and verify the OTP

"use client";

import { useState } from "react";
import { useCapxulAuth, useCapxulIdentity } from "@capxul/sdk-react";

export default function LoginPage() {
  const identity = useCapxulIdentity();
  const auth = useCapxulAuth();
  const [email, setEmail] = useState("");
  const [otp, setOtp] = useState("");

  if (identity.phase === "signed_out") {
    return (
      <form
        onSubmit={(event) => {
          event.preventDefault();
          void auth.requestCode(email);
        }}
      >
        <input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
        <button type="submit">Send code</button>
      </form>
    );
  }

  if (identity.phase === "otp_pending") {
    return (
      <form
        onSubmit={(event) => {
          event.preventDefault();
          void auth.verifyCode(otp);
        }}
      >
        <input inputMode="numeric" value={otp} onChange={(event) => setOtp(event.target.value)} />
        <button type="submit">Verify</button>
      </form>
    );
  }

  if (identity.phase === "faulted") return <p>{identity.failure.code}</p>;
  if (identity.phase !== "authenticated") return <p>Working…</p>;
  return <p>Signed in as {identity.session.email}</p>;
}

For production UI, prefer the exhaustive CapxulAuthenticationController and supply your own slots.

Complete onboarding and route

Call useCapxulAuth().completePersonal(profile, options) for the personal journey. Use CapxulOnboardingController for Organization onboarding and response-loss recovery.

useCapxulDestination() returns the canonical destination descriptor. Map it to your routes; null means account truth is still unresolved.

Verify the boundary

  • Reload after OTP verification and confirm the identity runtime restores the session.
  • Exercise a Profile write, account claim, and destination change.
  • For Organization onboarding, reload after submit and replay the stored exact request with the same journey ID and a fresh correlation ID.
  • Sign out and confirm authenticated rich-data queries are cleared.

Continue with Build the auth flow and Organizations.

Compile-checked Next.js example

These snippets are the maintained quickstart fixture. Copy them as a unit.

next.config.ts
import type { NextConfig } from "next";

const capxulSiteUrl = process.env.CAPXUL_SITE_URL?.replace(/\/$/, "");

if (!capxulSiteUrl) {
  throw new Error("CAPXUL_SITE_URL is required");
}

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: "/v1/client/bootstrap",
        destination: `${capxulSiteUrl}/v1/client/bootstrap`,
      },
      {
        source: "/api/auth/:path*",
        destination: `${capxulSiteUrl}/api/auth/:path*`,
      },
    ];
  },
};

export default nextConfig;
src/app/providers.tsx
"use client";

import { CapxulProvider } from "@capxul/sdk-react";

export function Providers({
  publishableKey,
  children,
}: {
  publishableKey: string;
  children: React.ReactNode;
}) {
  return (
    <CapxulProvider publishableKey={publishableKey} requirement="deployed">
      {children}
    </CapxulProvider>
  );
}
src/app/layout.tsx
import type { Metadata } from "next";

import { Providers } from "./providers";

export const metadata: Metadata = {
  title: "Capxul Next.js Quickstart",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  const publishableKey = process.env.NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY;

  if (!publishableKey) {
    throw new Error("NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY is required");
  }

  return (
    <html lang="en">
      <body>
        <Providers publishableKey={publishableKey}>{children}</Providers>
      </body>
    </html>
  );
}
src/app/login/page.tsx
"use client";

import { type FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useCapxul, useCapxulAuth, useCapxulIdentity, useCapxulSend } from "@capxul/sdk-react";

export default function LoginPage() {
  const bootstrap = useCapxul();
  if (bootstrap.status === "bootstrapping") return <main>Starting Capxul...</main>;
  if (bootstrap.status === "error") {
    return (
      <main>
        <p>Capxul could not start: {bootstrap.error?.message ?? "Unknown error"}</p>
        <button type="button" onClick={bootstrap.retry}>
          Retry
        </button>
      </main>
    );
  }
  return <ReadyLogin />;
}

function ReadyLogin() {
  const router = useRouter();
  const identity = useCapxulIdentity();
  const auth = useCapxulAuth();
  const send = useCapxulSend();
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [refusal, setRefusal] = useState<string | null>(null);

  async function sendOtp(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const result = await auth.requestCode(email);
    setRefusal(result.ok ? null : result.reason);
  }

  async function verify(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const result = await auth.verifyCode(code);
    if (result.ok) router.replace("/setup");
    else setRefusal(result.reason);
  }

  if (identity.phase === "authenticated") {
    return (
      <main>
        <p>You are signed in as {identity.session.email}.</p>
        <button type="button" onClick={() => router.replace("/setup")}>
          Continue
        </button>
      </main>
    );
  }
  if (
    identity.phase === "restoring" ||
    identity.phase === "otp_sending" ||
    identity.phase === "otp_verifying" ||
    identity.phase === "signing_out"
  ) {
    return <main>Working...</main>;
  }
  if (identity.phase === "faulted") {
    const recovery =
      identity.resume === null
        ? { _tag: "Reset" as const }
        : { _tag: "ResumeOtpEntry" as const, now: Date.now() };
    return (
      <main>
        <p>{identity.failure.message}</p>
        <button type="button" onClick={() => void send(recovery)}>
          Try again
        </button>
      </main>
    );
  }

  return (
    <main>
      <h1>Sign in</h1>
      {identity.phase === "signed_out" ? (
        <form onSubmit={sendOtp}>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            type="email"
            autoComplete="email"
            required
            value={email}
            onChange={(event) => setEmail(event.target.value)}
          />
          <button type="submit">Send code</button>
        </form>
      ) : (
        <form onSubmit={verify}>
          <label htmlFor="code">One-time code</label>
          <input
            id="code"
            inputMode="numeric"
            autoComplete="one-time-code"
            required
            value={code}
            onChange={(event) => setCode(event.target.value)}
          />
          <button type="submit">Verify and continue</button>
        </form>
      )}
      {refusal === null ? null : <p>{refusal}</p>}
    </main>
  );
}
src/app/setup/page.tsx
"use client";

import { type FormEvent, useState } from "react";
import Link from "next/link";
import { toCountryCode } from "@capxul/sdk";
import { useCapxul, useCapxulAuth, useCapxulIdentity, useCapxulProfile } from "@capxul/sdk-react";

export default function SetupPage() {
  const bootstrap = useCapxul();
  if (bootstrap.status !== "ready") return <main>Loading your Capxul account...</main>;
  return <ReadySetup />;
}

function ReadySetup() {
  const identity = useCapxulIdentity();
  const profile = useCapxulProfile();
  if (identity.phase !== "authenticated") {
    return (
      <main>
        <Link href="/login">Go to sign in</Link>
      </main>
    );
  }
  if (profile.isLoading) return <main>Loading your Capxul account...</main>;
  if (profile.error) return <main>Could not read your profile: {profile.error.message}</main>;
  if (!identity.profileComplete) return <PersonalOnboarding />;
  return <AccountReadiness />;
}

function PersonalOnboarding() {
  const auth = useCapxulAuth();
  const [displayName, setDisplayName] = useState("");
  const [country, setCountry] = useState("");
  const [refusal, setRefusal] = useState<string | null>(null);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const result = await auth.completePersonal({ displayName, country: toCountryCode(country) });
    setRefusal(result.ok ? null : result.reason);
  }

  return (
    <main>
      <h1>Set up your profile</h1>
      <form onSubmit={submit}>
        <label htmlFor="displayName">Display name</label>
        <input
          id="displayName"
          required
          value={displayName}
          onChange={(event) => setDisplayName(event.target.value)}
        />
        <label htmlFor="country">Two-letter country code</label>
        <input
          id="country"
          required
          minLength={2}
          maxLength={2}
          value={country}
          onChange={(event) => setCountry(event.target.value.toUpperCase())}
        />
        <button type="submit">Continue</button>
      </form>
      {refusal === null ? null : <p>{refusal}</p>}
    </main>
  );
}

function AccountReadiness() {
  const identity = useCapxulIdentity();
  const auth = useCapxulAuth();
  if (identity.phase !== "authenticated") return null;

  if (identity.account.at === "failed") {
    return (
      <main>
        <h1>Account setup needs attention</h1>
        <p>{identity.account.failure.message}</p>
        {identity.account.retryable ? (
          <button type="button" onClick={() => void auth.retry()}>
            Retry setup
          </button>
        ) : null}
      </main>
    );
  }
  if (identity.account.at !== "claimed") {
    return (
      <main>
        <h1>Setting up your account...</h1>
        <p>Current state: {identity.account.at}</p>
      </main>
    );
  }
  return (
    <main>
      <h1>Your account is ready</h1>
      <p>Money movement is enabled.</p>
      <Link href="/dashboard">Open the dashboard</Link>
    </main>
  );
}
src/app/dashboard/page.tsx
"use client";

import Link from "next/link";
import { useCapxul, useCapxulAuth, useCapxulIdentity } from "@capxul/sdk-react";

export default function DashboardPage() {
  const bootstrap = useCapxul();
  if (bootstrap.status !== "ready") return <main>Starting Capxul...</main>;
  return <ReadyDashboard />;
}

function ReadyDashboard() {
  const identity = useCapxulIdentity();
  const auth = useCapxulAuth();
  if (identity.phase !== "authenticated") return <Link href="/login">Sign in</Link>;

  return (
    <main>
      <h1>Dashboard</h1>
      <p>Signed in as {identity.session.email}</p>
      <p>Account status: {identity.account.at}</p>
      <button type="button" onClick={() => void auth.signOut()}>
        Sign out
      </button>
    </main>
  );
}

On this page