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.
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;"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>
);
}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>
);
}"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>
);
}"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>
);
}"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>
);
}