ZevAuth
Sign up

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

PropMeaning
publishableKeyRequired. pk_test_… or pk_live_…. Read it from GET /v1/environment if you are seeding config automatically.
apiUrlWhere the API lives. Defaults to https://api.zevauth.net.
afterSignInUrlWhere <SignIn /> sends people once they are signed in.
appearanceBranding 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:

StyleWhat you get
GradientYour accent, or a colour you pick, swept into a darker corner
SolidThat colour, flat
ImageA 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