Quickstart
From an empty React app to a working Capxul email OTP sign-in — install the SDK, get a test key, mount the provider, and read the session.
This is the shortest path to a working Capxul sign-in. By the end, your local app can request a one-time code by email, verify it, and show the signed-in session. You do not need to understand Capxul's backend to complete it.
The steps below use Vite because it is the fastest way to a running React app. Building on Next.js? Follow the Next.js guide instead — same flow, framework-specific wiring.
Create an app and install the SDK
npm create vite@latest capxul-quickstart -- --template react-ts
cd capxul-quickstart
npm install
npm install @capxul/sdk@1.0.0-alpha.13 @capxul/sdk-react@1.0.0-alpha.13 @tanstack/react-queryPin the exact versions shown. The packages publish on the 1.0.0-alpha.x
train, and these docs are verified against 1.0.0-alpha.13 — see
Capability status for what that version can do.
@tanstack/react-query v5 is a peer dependency of @capxul/sdk-react.
Get a test key
npx @capxul/sandbox key createWhen the CLI asks for the allowed local origin, enter
http://localhost:5173 — Vite's dev origin. The CLI creates a test
publishable key and writes it to a local handoff file (it never prints the raw
key to the terminal). Open the handoff file and keep two values nearby: the
publishable key and the Capxul site URL.
Full walkthrough of the CLI, the handoff file, and test funds: Test keys & funds.
Add environment variables
Create .env.local in the project root:
VITE_CAPXUL_PUBLISHABLE_KEY=<publishable key from the handoff file>
CAPXUL_SITE_URL=<Capxul site URL from the handoff file>Only the publishable key belongs in the browser, so only it gets the VITE_
prefix. CAPXUL_SITE_URL is dev-server configuration — the next step reads it
in vite.config.ts — and without the prefix Vite keeps it out of the browser
bundle.
Proxy the two Capxul routes
Replace vite.config.ts:
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const capxulSiteUrl = env.CAPXUL_SITE_URL;
if (!capxulSiteUrl) {
throw new Error("CAPXUL_SITE_URL is required");
}
return {
plugins: [react()],
server: {
proxy: {
"/v1/client/bootstrap": {
target: capxulSiteUrl,
changeOrigin: true,
secure: true,
},
"/api/auth": {
target: capxulSiteUrl,
changeOrigin: true,
secure: true,
},
},
},
};
});Why this exists: the SDK boots by calling /v1/client/bootstrap on your
app's own origin, and sign-in stores its session cookies under same-origin
/api/auth/*. The proxy forwards both paths to Capxul while the browser keeps
treating them as your origin — which is what makes the cookies work.
Mount CapxulProvider
Replace src/main.tsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { CapxulProvider } from "@capxul/sdk-react";
import App from "./App";
const publishableKey = import.meta.env.VITE_CAPXUL_PUBLISHABLE_KEY;
if (!publishableKey) {
throw new Error("VITE_CAPXUL_PUBLISHABLE_KEY is required");
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<CapxulProvider publishableKey={publishableKey}>
<App />
</CapxulProvider>
</StrictMode>,
);The provider owns the whole client lifecycle: it bootstraps from the
publishable key, creates the TanStack Query cache, and tears everything down
on unmount. You do not mount a QueryClientProvider or call any client
factory yourself.
Build the sign-in form
Replace src/App.tsx:
import { type FormEvent, useState } from "react";
import {
useCapxul,
useCapxulSession,
useCapxulSignIn,
useCapxulSignOut,
useCapxulVerifyOtp,
} from "@capxul/sdk-react";
export default function App() {
const bootstrap = useCapxul();
const session = useCapxulSession();
const signIn = useCapxulSignIn();
const verifyOtp = useCapxulVerifyOtp();
const signOut = useCapxulSignOut();
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [otpSent, setOtpSent] = useState(false);
async function sendOtp(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await signIn.mutateAsync({ email });
setOtpSent(true);
}
async function verify(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await verifyOtp.mutateAsync({ email, code });
}
if (bootstrap.status === "bootstrapping" || session.isLoading) {
return <main>Loading Capxul…</main>;
}
if (bootstrap.status === "error") {
return (
<main>
<p>Capxul failed to start: {bootstrap.error?.message ?? "Unknown error"}</p>
<button type="button" onClick={bootstrap.retry}>
Retry
</button>
</main>
);
}
if (session.data) {
return (
<main>
<h1>Signed in</h1>
<p>Email: {session.data.email}</p>
<p>User id: {session.data.authUserId}</p>
<button type="button" onClick={() => signOut.mutate()}>
Sign out
</button>
</main>
);
}
return (
<main>
<h1>Sign in</h1>
{!otpSent ? (
<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" disabled={signIn.isPending}>
{signIn.isPending ? "Sending…" : "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" disabled={verifyOtp.isPending}>
{verifyOtp.isPending ? "Verifying…" : "Verify and sign in"}
</button>
</form>
)}
{signIn.error ? <p>{signIn.error.message}</p> : null}
{verifyOtp.error ? <p>{verifyOtp.error.message}</p> : null}
</main>
);
}Three calls do the work:
signIn.mutateAsync({ email })sends the one-time code to the email.verifyOtp.mutateAsync({ email, code })verifies it. If the email has never signed in before, Capxul creates the user identity here — there is no separate "create user" call.useCapxulSession()reads the signed-in state; it flips fromnullto a session the moment verification succeeds, because the mutation invalidates the auth queries for you.
Run it
npm run devOpen http://localhost:5173, enter an email you can read, submit the code from the inbox, and the page should show Signed in with the email and user id.
If the app is stuck on a bootstrap error instead, the usual cause is an origin
mismatch: the test key must allow http://localhost:5173 exactly. Re-run
npx @capxul/sandbox key create with the right origin if needed.
Where to go next
- Next.js — the same flow wired into a Next.js App Router app.
- Requirements & signers — what to add when your app needs an account and money UI, not just sign-in.
- Test keys & funds — the sandbox CLI in full, including test money.
- Hooks reference — the full hook catalog behind these five imports.
- Capability status — what is live in the published alpha before you build further.