Building with the SDK
React
Hooks, control components and the provider.
npm install @zevauth/react
Everything starts with the provider. It creates one client, loads the session, and keeps React in step with it.
import { ZevAuthProvider } from '@zevauth/react';
<ZevAuthProvider publishableKey="pk_test_...">
<App />
</ZevAuthProvider>
Provider props
| Prop | Meaning |
|---|---|
publishableKey | Required. pk_test_… or pk_live_…. Read it from GET /v1/environment if you are seeding config automatically. |
apiUrl | Where the API lives. Defaults to https://api.zevauth.net. |
afterSignInUrl | Where <SignIn /> sends people once they are signed in. |
appearance | Branding overridden in code. See Customising the components. |
apiUrl
Give the origin only. The SDK appends its own paths, so /v1 must not be
part of what you pass:
// Correct
<ZevAuthProvider publishableKey="pk_test_..." apiUrl="https://api.zevauth.net">
// Wrong: requests go to /v1/v1/environment and 404
<ZevAuthProvider publishableKey="pk_test_..." apiUrl="https://api.zevauth.net/v1">
A relative value works too, which is what you want if you proxy us through your own dev server:
// vite.config.ts
server: { proxy: { '/zevauth': { target: 'https://api.zevauth.net', changeOrigin: true, rewrite: (p) => p.replace(/^\/zevauth/, '') } } }
<ZevAuthProvider publishableKey="pk_test_..." apiUrl="/zevauth">
Split sign-in screens
By default <SignIn /> is one card in the middle of the page. It can also be a
split screen, with the form on one side and a panel on the other:
<SignIn layout="split-right" /> // form on the right
<SignIn layout="split-left" /> // form on the left
<SignUp /> takes the same two props and follows the same console setting, so
your two screens keep one shape. The password reset and verification code steps
are stages of <SignIn />, so they inherit the layout with nothing to set.
The name says where the form goes, because that is the thing you are positioning.
You do not have to set it in code. Customisation → Layout in the console offers the same three choices, and the component follows it. Use the prop when you want the decision to live in your repository, and it wins over the console.
What fills the other side
Choose what the panel is made of in the console, under the layout picker:
| Style | What you get |
|---|---|
| Gradient | Your accent, or a colour you pick, swept into a darker corner |
| Solid | That colour, flat |
| Image | A banner you upload, with a scrim under the text |
Then add a heading and a line of text on top of it, which is where a short quote or a product line goes. Nobody needs to touch the code for any of that.
It is a choice rather than “an image if one is uploaded”, so trying a colour does not mean deleting a banner you spent time on.
For anything richer, pass your own markup:
<SignIn
layout="split-right"
aside={
<div style={{ padding: 48 }}>
<h2>Built for teams</h2>
<p>One balance, every tool.</p>
</div>
}
/>
aside replaces the console picture and copy rather than joining them: passing
JSX is the more specific instruction.
Overriding the console from code
Every branding value can be set in code as well as in the console, on the provider or on one component:
<ZevAuthProvider publishableKey="pk_live_..." appearance={{ cardStyle: 'flat' }}>
Anything you leave out keeps whatever the console is set to. The full list of fields, with when to use which, is in Customising the components.
Sign in with ZevID
<SignIn /> shows the ZevID button whenever the provider is enabled under
Authentication → Social providers. One more step, and it is the one people
miss: register where the person should land afterwards, under Project
settings → Domains & callbacks. Until then the button reaches our API and
comes back with No callback is registered for this environment.
http://localhost:5173 (or whatever your dev server uses) is a valid callback in
a development environment, so the flow can be tested end to end on your machine.
<SignIn afterSignInUrl="/dashboard" />
Programmatically:
client.signIn.withZevId({ redirectUrl: 'https://yourapp.com/after' });
redirectUrl is optional when exactly one callback is registered.
When it fails, the person comes back to your callback with an error
instead of a code. <SignIn /> shows the reason in the box they started from,
with nothing for you to wire. If you render your own sign-in, read it once:
const failure = client.takeHandoffError();
if (failure?.code === 'zevid_expired') showToast('That took too long. Try again.');
The codes and what each means are in the API reference.
Just the ZevID button, in your own page
You do not have to adopt our whole sign-in screen to offer Sign in with ZevID. If you already have your own login, or a member portal with its own accounts, drop in the button on its own the way you would a “Sign in with Google”:
import { SignInWithZevId } from '@zevauth/react';
<SignInWithZevId label="Connect with ZevID" shape="pill" afterSignInUrl="/account" />
It carries the Zev mark, no card and no attribution, because it is a control
inside your layout rather than a screen of ours. label and shape
(default, pill, square) let it match the buttons already on the page.
Two common places for it:
- On your login, beside your own fields, as one more way in.
- On an existing member’s profile, as “Connect with ZevID”, so a user who
already has an account with you links their ZevID. This is the first half of
member attestation: the flow returns the ZevID
accountId, which your backend then attests. Your own authentication stays exactly as it is; ZevAuth is added alongside, not in place of it.
Ordering the sign-in methods
<SignIn /> puts social sign-in above the form by default. If most of your
users sign in with a password, that puts the primary path second.
<SignIn secondaryMethods="collapsed" />
collapsed shows the email and password form first and folds everything else,
ZevID included, behind a More options toggle. inline is the default and
shows them all expanded, with social sign-in first.
Sign-up and verification
<SignUp /> {/* asks for the password twice */}
<SignUp requireConfirmPassword={false} /> {/* one field, if you prefer */}
The confirmation screen renders a code field when your environment sends codes
and link instructions when it sends links, decided from
GET /v1/environment rather than assumed.
Gate your own UI on whether the address is confirmed:
import { EmailVerified, EmailUnverified } from '@zevauth/react';
<EmailUnverified><Banner>Confirm your email.</Banner></EmailUnverified>
<EmailVerified><PayoutSettings /></EmailVerified>
Both render nothing until the session loads, so neither flashes the wrong
branch. They decide what to render, not what is allowed: your API must still
check the email_verified claim.
useUser
const { isLoaded, isSignedIn, user } = useUser();
Three states, not two. On the first render of a page there is no answer yet. A session may be restorable from storage, and finding out takes a request.
if (!isLoaded) return <Skeleton />;
if (!isSignedIn) return <SignIn />;
return <p>Hello {user.firstName}</p>;
useAuth
The credential-shaped view: ids and a token, no profile. Use it where you only need to attach a token, so the component does not re-render every time a display name changes.
const { userId, organizationId, getToken, signOut } = useAuth();
const token = await getToken();
getToken() refreshes first if the token is close to expiring. getToken and
signOut keep a stable identity across renders, so they are safe in a
dependency array.
useOrganization and useOrganizationList
const { organization } = useOrganization();
const { organizations, setActive } = useOrganizationList();
await setActive('org_...'); // null returns to personal scope
Switching rotates the session’s tokens, because the organization is a claim inside them. See Organizations.
Members are fetched only when you ask:
const { members, isLoadingMembers } = useOrganization({ withMembers: true });
Control components
<SignedIn> …only when signed in… </SignedIn>
<SignedOut> …only when signed out… </SignedOut>
<ZevAuthLoading> …while the session resolves… </ZevAuthLoading>
And <Protect> for role and permission checks:
<Protect permission="org:members:manage" fallback={<p>Ask an admin.</p>}>
<InviteForm />
</Protect>
Errors
Anything the SDK throws is a ZevAuthError with a code you can branch on and
a message written for a person to read.
try {
await client.signIn.withPassword({ identifier, password });
} catch (err) {
if (err instanceof ZevAuthError) setError(err.message);
} Updated at, Wednesday, October 21, 2026