Documentation
Build withFramework quickstartsReact

React

A session that survives reloads, and routes that redirect when it ends.

Everything here runs in the browser, against the publishable key. A complete flow: a client, a sign-in form that handles MFA, a hook that tracks the session, and a protected route.

For permission checks that a user must not be able to skip, pair this with Node on your own API.

Install

npm install @nulvless/nulvless-js

Create the client

One module, imported everywhere. The publishable key is safe in the bundle.

src/lib/nulvless.ts
import { createClient, Storage } from '@nulvless/nulvless-js';

export const nulvless = createClient(
  import.meta.env.VITE_NULVLESS_URL,
  import.meta.env.VITE_NULVLESS_KEY,
  { storage: Storage.Local, trackUser: true },
);

Storage.Local keeps people signed in between visits. trackUser lets the platform record the device and location on each session.

Track the session

The SDK emits events when authentication changes anywhere in the app, so one hook is enough.

src/lib/use-session.ts
import { useEffect, useState } from 'react';
import type { Session } from '@nulvless/nulvless-js';
import { nulvless } from './nulvless';

export function useSession() {
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    nulvless.auth.getSession().then(({ data }) => {
      setSession(data);
      setLoading(false);
    });

    const offIn = nulvless.auth.on('signed-in', () => {
      nulvless.auth.getSession().then(({ data }) => setSession(data));
    });
    const offOut = nulvless.auth.on('signed-out', () => setSession(null));
    const offDead = nulvless.auth.on('unauthorized', () => setSession(null));

    return () => {
      offIn();
      offOut();
      offDead();
    };
  }, []);

  return { session, loading };
}

on returns its unsubscribe function — return them from the effect and React cleans up on unmount.

Sign in

The form has to handle two outcomes: a session, or a challenge when the user has MFA enrolled.

src/SignIn.tsx
import { useState } from 'react';
import type { Challenge } from '@nulvless/nulvless-js';
import { nulvless } from './lib/nulvless';

export function SignIn() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [challenge, setChallenge] = useState<Challenge | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function submit(event: React.FormEvent) {
    event.preventDefault();
    setError(null);

    const { data, error } = await nulvless.auth.signIn({ email, password });

    if (error) return setError('Check your email and password.');
    if (data.challenge) return setChallenge(data.challenge);
    // Signed in — the session is stored and the event fired.
  }

  if (challenge) return <VerifyCode challenge={challenge} />;

  return (
    <form onSubmit={submit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
      <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  );
}

Collect the code

One component serves every challenge — MFA, OTP sign-in, sudo — because challenge.subject says which flow you are in.

src/VerifyCode.tsx
import { useState } from 'react';
import type { Challenge } from '@nulvless/nulvless-js';
import { nulvless } from './lib/nulvless';

export function VerifyCode({ challenge }: { challenge: Challenge }) {
  const [code, setCode] = useState('');
  const [error, setError] = useState<string | null>(null);

  async function submit(event: React.FormEvent) {
    event.preventDefault();

    const { error } = await nulvless.auth.verifyChallenge({
      token: challenge.token,
      code,
    });

    if (error) setError('That code did not work.');
  }

  return (
    <form onSubmit={submit}>
      <p>We sent a code to {challenge.identifier}</p>
      <input value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Confirm</button>
    </form>
  );
}

challenge.identifier is already masked — u***@example.com — so it is safe to show.

Protect a route

src/Protected.tsx
import { useSession } from './lib/use-session';
import { SignIn } from './SignIn';

export function Protected({ children }: { children: React.ReactNode }) {
  const { session, loading } = useSession();

  if (loading) return null;
  if (!session) return <SignIn />;

  return children;
}

Sign out

<button onClick={() => nulvless.auth.signOut()}>Sign out</button>

Pass 'others' to drop every other session and keep this one, or 'global' to end them all.

Social sign-in

<button onClick={() => nulvless.auth.authorize('google', {
  redirect_uri: `${window.location.origin}/callback`,
})}>
  Continue with Google
</button>

The SDK redirects for you. On the callback route:

useEffect(() => {
  const code = new URLSearchParams(window.location.search).get('code');
  if (code) nulvless.auth.exchangeCodeForToken(code);
}, []);

Render the buttons from getMethods() rather than hard-coding providers — turning one on in the console then needs no redeploy.

Next

On this page