Capxul Docs
Getting started

Next.js

Wire Capxul email OTP sign-in into a Next.js App Router app — environment variables, route rewrites, provider mounting, and a login page.

This guide takes a fresh Next.js App Router app to a working Capxul email OTP login. It is the Next.js version of the Quickstart and has been validated end-to-end against a real integration.

You need two values before starting: a publishable key and a Capxul site URL. Get both from the sandbox CLI — Test keys & funds — or from a secure handoff Capxul gave you. Only the publishable key ever belongs in a browser env var. Never put backend secrets, private keys, or operator credentials in a Next.js app.

Install packages

From a fresh Next.js app:

npm install @capxul/sdk@1.0.0-alpha.13 @capxul/sdk-react@1.0.0-alpha.13 @tanstack/react-query

Pin exact versions. The packages publish on the 1.0.0-alpha.x train; do not assume latest or the alpha tag points at the build your handoff was validated against.

Add environment variables

Create .env.local:

NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY=<your publishable key>
CAPXUL_SITE_URL=<your Capxul site URL>

For local development the key must allow http://localhost:3000 — that is the origin you gave the CLI when creating it.

CAPXUL_SITE_URL is not a secret, but it is server-side configuration — it is read by next.config.ts in the next step, and nothing in the browser needs it. Do not prefix it with NEXT_PUBLIC_; keeping it server-side means it never ships in the client bundle.

Proxy Capxul routes

Create or update next.config.ts:

import type { NextConfig } from "next";
import { loadEnvConfig } from "@next/env";

loadEnvConfig(process.cwd());

const capxulSiteUrl = process.env.CAPXUL_SITE_URL;

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;

Two things here are load-bearing:

  • Why the rewrites exist. The browser SDK intentionally boots by calling /v1/client/bootstrap on your app's own origin, and sign-in keeps its session cookies under same-origin /api/auth/*. The rewrites forward both paths to the Capxul backend while the browser keeps the cookies on your app's origin — which is what makes the session work.
  • Why loadEnvConfig is there. next.config.ts runs before the app runtime, which loads .env.local for you — the config file does not get that for free. Without the explicit @next/env load, CAPXUL_SITE_URL can be missing during local builds even though the app itself sees NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY just fine.

Mount CapxulProvider

Create app/capxul-provider.tsx:

"use client";

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

export function Providers({ children }: { 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 (
    <CapxulProvider publishableKey={publishableKey}>{children}</CapxulProvider>
  );
}

Wrap the app in app/layout.tsx:

import type { Metadata } from "next";
import { Providers } from "./capxul-provider";
import "./globals.css";

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

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

The provider owns bootstrap, the TanStack Query cache, and client teardown. You do not mount QueryClientProvider or call a client factory yourself.

Build the login page

Create app/page.tsx:

"use client";

import { type FormEvent, useState } from "react";
import {
  useCapxul,
  useCapxulSession,
  useCapxulSignIn,
  useCapxulSignOut,
  useCapxulVerifyOtp,
} from "@capxul/sdk-react";

export default function Home() {
  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);
  const [message, setMessage] = useState<string | null>(null);

  async function sendOtp(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setMessage(null);
    await signIn.mutateAsync({ email });
    setOtpSent(true);
    setMessage("Check your email for the one-time code.");
  }

  async function verify(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setMessage(null);
    await verifyOtp.mutateAsync({ email, code });
    setMessage("Logged in.");
  }

  if (bootstrap.status === "bootstrapping" || session.isLoading) {
    return <main>Loading Capxul...</main>;
  }

  if (bootstrap.status === "error") {
    return (
      <main>
        <p>
          Capxul bootstrap failed: {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>Capxul login</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 log in"}
          </button>
        </form>
      )}

      {message ? <p>{message}</p> : null}
      {signIn.error ? <p>{signIn.error.message}</p> : null}
      {verifyOtp.error ? <p>{verifyOtp.error.message}</p> : null}
    </main>
  );
}

The verifyOtp call is both "create user" and "log in": if the email has never signed in before, Capxul creates the user identity during verification. Your app does not call a separate create-user API.

Import only from @capxul/sdk and @capxul/sdk-react package roots. Deep imports into package internals are not public API and will break between alpha releases.

Validate

Run through this checklist before calling the integration done:

  1. The key allows http://localhost:3000 (the origin you gave the CLI).
  2. CAPXUL_SITE_URL points at the Capxul backend from your handoff.
  3. npm run build passes — this proves next.config.ts sees CAPXUL_SITE_URL (the @next/env load above).
  4. Start npm run dev, send a code to a test email, verify it, and confirm the page shows the session email and user id. A profile can be null for a newly created identity — the session is the proof point here, not the profile.
  5. Before deploying, get your production origin added to both origin gates: the publishable key's origin allowlist covers only bootstrap; the Capxul backend keeps separate trusted-origin and CORS allowlists (CAPXUL_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS) that must also include your origin. If bootstrap succeeds but auth calls fail, this split is the usual suspect.

Beyond login

Stop here if the product only needs sign-in. If it also needs an account, balance, or money movement, the next decision is the requirement prop on CapxulProvider — read Requirements & signers, then the hooks reference for the money hooks and Capability status for what is live in the current alpha.

On this page