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-queryPin 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/bootstrapon 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
loadEnvConfigis there.next.config.tsruns before the app runtime, which loads.env.localfor you — the config file does not get that for free. Without the explicit@next/envload,CAPXUL_SITE_URLcan be missing during local builds even though the app itself seesNEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEYjust 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:
- The key allows
http://localhost:3000(the origin you gave the CLI). CAPXUL_SITE_URLpoints at the Capxul backend from your handoff.npm run buildpasses — this provesnext.config.tsseesCAPXUL_SITE_URL(the@next/envload above).- 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 benullfor a newly created identity — the session is the proof point here, not the profile. - 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.