Getting started
Use cases
Complete, working setups for the shapes most products actually need.
Reference pages answer “what does this field do”. This page answers “how do I build the thing I came here to build”. Each one is a whole setup: what to switch on in the console, what to write in code, and what it gets you.
A SaaS that confirms every address before letting anyone in
Financial tools, anything with payouts, anything where an unconfirmed address is a liability.
In the console
- Authentication → Identifiers → Email: Verify the address At sign-up.
- Turn Let them in before it is confirmed off.
In code
import { ZevAuthProvider, SignIn, SignUp } from '@zevauth/react';
<ZevAuthProvider publishableKey={import.meta.env.VITE_ZEVAUTH_PUBLISHABLE_KEY}>
<SignUp afterSignUpUrl="/welcome" />
</ZevAuthProvider>
What happens. Sign-up creates the account and issues no session.
<SignUp /> shows the confirmation screen, with a code field when your
environment sends codes and link instructions when it sends links.
If they close the tab and come back a week later, signing in is refused
with email_not_verified and we send a fresh message. They are returned
to the confirmation screen rather than stranded with an expired link.
A product that lets people in, then nags them
Marketplaces, social products, anything where friction at sign-up costs you more than an unconfirmed address does.
In the console
- Verify the address At sign-up.
- Leave Let them in before it is confirmed on.
In code
import { SignedIn, EmailVerified, EmailUnverified } from '@zevauth/react';
<SignedIn>
<EmailUnverified>
<Banner>
Confirm your email to enable payouts. <ResendButton />
</Banner>
</EmailUnverified>
<EmailVerified>
<PayoutSettings />
</EmailVerified>
</SignedIn>
Resending is one call:
await zevauth.signUp.resendVerification({ email: user.email });
On your own API, read the claim from the token you already verify:
const { payload } = await jwtVerify(token, jwks, { issuer, audience });
if (!payload.email_verified) {
return Response.json({ error: 'Confirm your email first.' }, { status: 403 });
}
Sign in with ZevID, tested on localhost
The ZevID button needs one thing beyond being switched on, and it is the thing almost everyone misses on the first try.
In the console
- Authentication → Social providers: turn ZevID on.
- Project settings → Domains & callbacks: add where the person should land
afterwards. For local work that is your dev server, for example
http://localhost:5173/. Development environments accepthttpon loopback; production must behttps.
In code
<SignIn afterSignInUrl="/dashboard" />
That is all. The button redirects to the consent screen and back to the registered callback, where the SDK exchanges the returned code for a session on your own origin.
If the person comes straight back without a session, they arrived with
?error= on your callback. <SignIn /> already shows why; the codes are in
the API reference. The common one is
zevid_expired: they took more than 15 minutes across OTP, MFA and consent.
Offer the button again.
If you see No callback is registered for this environment, step 2 is
missing. If you see a URL named as not a registered callback, it does not
match an entry exactly: check the scheme, the port and the trailing slash.
A marketplace with buyers, sellers and staff
Staff go in a second project. An environment is a hard tenant boundary: separate user pool, separate keys, separate issuer. A customer token presented to your admin API fails on the signature, not on a check somebody has to remember to write.
Buyers and sellers share one project, typed with metadata:
await zevauth.users.update(userId, { publicMetadata: { type: 'seller' } });
The type travels in the access token, so your API reads it with no extra call, and lists come back filtered:
const { data } = await zevauth.users.list({ metadata: { type: 'seller' } });
publicMetadata is writable only with a secret key, so a value you set
is a value only your server set. Full reasoning in
Modelling your users.
A sign-in page that looks like the rest of the product
The default is a centered card. This is the split screen most products in this category ship:
In the console: Customisation → Layout → Form right, then pick a Gradient panel and give it a colour and a line of copy.
Or entirely in code:
<SignIn
layout="split-right"
aside={
<div style={{ padding: 48, color: 'white' }}>
<blockquote>"We moved 40,000 accounts in an afternoon."</blockquote>
<cite>Ada Lovelace, CTO</cite>
</div>
}
/>
The panel hides below 900px and the form takes the whole screen, because a stacked picture pushes the form below the fold on a phone.
A form embedded in a page you have already designed
You have a header, a panel and a font. Our card inside your panel is a box inside a box:
<ZevAuthProvider
publishableKey="pk_live_..."
appearance={{
cardStyle: 'flat', // no border, background or padding
showIdentity: false, // your logo is already above it
fontFamily: 'inherit', // your font
contentAlign: 'top', // clears your fixed header
signInTitle: 'Welcome back',
}}
>
<MyHeader />
<main className="my-panel">
<SignIn secondaryMethods="collapsed" />
</main>
</ZevAuthProvider>
secondaryMethods="collapsed" puts the email and password form first and
folds social sign-in behind More options, which is what you want when
most of your users are returning customers with a password.
A B2B app where people belong to companies
const { organization } = await zevauth.organizations.create({
name: 'Acme Supplies',
createdBy: userId,
});
The session then carries org_id, org_role and org_permissions. Use
the claims to decide what to render, and re-read the membership before
anything that changes data: a token minted before somebody was demoted
still carries the old permissions until it refreshes.
<OrganizationSwitcher />
See Organizations.
Testing all of it
// A user, and a token, with no password anywhere in your fixtures.
const user = await zevauth.users.create({ email: 'ada@example.com' });
const { accessToken } = await zevauth.sandbox.mintToken(user.id);
// What we would have emailed, including the code.
const { data } = await zevauth.sandbox.emails({ to: 'ada@example.com' });
await zevauth.signUp.verifyCode({ email: 'ada@example.com', code: data[0].code });
// Between CI runs.
await zevauth.sandbox.resetUsers();
Test environments only, and the API refuses every one of these with a live key. See Testing.
Updated at, Wednesday, October 21, 2026