Capxul Docs
Getting started

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-query

Pin 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 create

When 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 from null to a session the moment verification succeeds, because the mutation invalidates the auth queries for you.

Run it

npm run dev

Open 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

On this page