Skip to main content
Documentation

Client auth reference

Hooks, providers, and components for authentication in React.

On this page

The auth surface is exported from deepspace. The Better Auth client is wrapped in a React provider, with hooks and components for everything you'd build by hand.

import {
  DeepSpaceAuthProvider,
  AuthGate, AuthOverlay,
  SignedIn, SignedOut, GuestBanner,
  useAuth, useAuthUser, useUser, useDisplayName,
  useAuthStatus, useAuthProfileReady,
  signIn, signOut, getAuthToken, clearAuthToken,
  authClient, useSession,
} from 'deepspace'
ts

Providers#

DeepSpaceAuthProvider#

Wraps the tree and initializes the Better Auth client. Required as an ancestor of every auth hook and component.

<DeepSpaceAuthProvider>
  <App />
</DeepSpaceAuthProvider>
tsx

The scaffolded _app.tsx already mounts this provider at the root. You don't normally render it yourself.

Hooks#

useAuth(): AuthState#

Primary auth-state hook. Session-based; updates immediately on sign-in / sign-out.

FieldTypeDescription
isLoadedbooleantrue once the first Better Auth session check resolves, and stays true for the page's lifetime - does not flap back to false on background refetches or tab refocus. Safe to gate RecordProvider / data-layer mounts on without remount churn.
isSignedInbooleanThe canonical signed-in check.
userIdstring | nullJWT subject; null when signed out.
sessionIdstring | nullBetter Auth session ID.
const { isLoaded, isSignedIn, userId } = useAuth()
if (!isLoaded) return <Skeleton />
if (!isSignedIn) return <SignInPrompt />
tsx

useAuthUser(): { isLoaded, isSignedIn, user }#

Returns the auth-layer user from Better Auth's session - { id, fullName, firstName, primaryEmailAddress, ... } | null. Use when you need fields from the OAuth provider's profile.

Different from useUser(), which merges in storage-layer fields like role and karma.

useUser(): { user, isLoading, refetch }#

Returns the storage-layer user, merged with the room-specific role from the app's users collection. Karma and credits are loaded from the API as nested objects when available.

type UserKarma = {
  total: number
  breakdown: { publishing: number; content: number; comment: number; curation: number }
  rank: number
  monthlyKarma: number
  monthlyRank: number
}

type UserCredits = {
  total: number
  subscription: number
  bonus: number
  purchased: number
}

type StorageUser = {
  id: string
  name: string
  email: string
  /** Free-form role string from the user-roles collection; defaults to `'viewer'`. */
  role: string
  imageUrl?: string
  isAdmin?: boolean
  publicUsername?: string | null
  subscriptionTier?: string | null
  subscriptionStatus?: string | null
  karma?: UserKarma | null
  credits?: UserCredits | null
}
ts
const { user, isLoading } = useUser()
if (isLoading) return null
if (user?.role !== 'admin') return <p>Admins only</p>

// Karma and credits are objects, not numbers:
const totalKarma = user?.karma?.total ?? 0
const availableCredits = user?.credits?.total ?? 0
tsx

useDisplayName(): string | null#

Resolves the best available display name (full name → first name → email username). Returns null while loading or signed out.

useAuthStatus(options?)#

Auth-only readiness as a single load state. Wraps useAuth() - safe outside RecordProvider (app shells, top-level layouts), because it never touches the record store.

function useAuthStatus(options?: { requireSignedIn?: boolean }): {
  // everything useAuth() returns:
  isLoaded: boolean
  isSignedIn: boolean
  userId: string | null
  sessionId: string | null
  // plus:
  status: 'loading' | 'ready' | 'empty' | 'error' | 'signedOut'
  isReady: boolean            // status === 'ready'
}
ts

status is 'loading' until the first session check resolves, 'signedOut' when requireSignedIn: true and the visitor isn't signed in, and 'ready' otherwise (the 'empty' / 'error' states belong to the wider load-state union and are not produced by this hook).

function AppShell({ children }: { children: ReactNode }) {
  const { isLoaded } = useAuthStatus()
  if (!isLoaded) return <div aria-busy="true" className="fixed inset-0" />
  return <>{children}</>
}
tsx

useAuthProfileReady(options?)#

Auth plus profile readiness, for profile-backed UI - user menus, role-gated nav, account names, admin controls. Combines useAuth() with useUser(), so it requires a RecordProvider ancestor.

function useAuthProfileReady(options?: {
  requireSignedIn?: boolean
  requireUser?: boolean
}): {
  // everything useAuth() returns, plus:
  user: StorageUser | null
  userLoading: boolean
  refetchUser: () => Promise<void>
  status: 'loading' | 'ready' | 'empty' | 'error' | 'signedOut'
  isReady: boolean
}
ts

With requireUser: true, status stays 'loading' while isSignedIn && userLoading - render a skeleton there instead of flashing signed-out UI at a signed-in user whose profile hasn't arrived yet:

function AccountMenu() {
  const { isSignedIn, user, status } = useAuthProfileReady({ requireUser: true })
  if (status === 'loading') return <MenuSkeleton />
  if (!isSignedIn) return <SignInButton />
  return <Menu name={user?.name} isAdmin={user?.role === 'admin'} />
}
tsx

Rule of thumb: useAuth().isSignedIn (or useAuthStatus()) for auth checks; useAuthProfileReady({ requireUser: true }) wherever the UI reads profile fields.

useSession() and authClient#

Re-exports from Better Auth for advanced flows (custom OAuth providers, magic links, etc.).

Components#

<AuthOverlay providers={...} />#

Modal sign-in UI. Render without an onClose prop and gate on !isSignedIn; auto-hides when signed in.

PropTypeDescription
providersArray<'github' | 'google'>Which OAuth buttons to show. Defaults to ['github', 'google']. Email/password sign-in is always available below the OAuth options.
onClose() => voidIf provided, renders a close button. Omit for non-dismissible.
<AuthOverlay providers={['google', 'github']} />
tsx

<AuthGate fallback={...} redirectOnSignOut={...} />#

Renders children when signed in; renders fallback (default: <AuthOverlay />) otherwise.

PropTypeDescription
fallbackReactNodeUI shown to signed-out users. Defaults to non-dismissible <AuthOverlay />.
redirectOnSignOutstringWhere the user lands on sign-out. Default '/'. Triggers a full reload.
<AuthGate fallback={<TeaserPage />}>
  <ProtectedContent />
</AuthGate>
tsx

<SignedIn> and <SignedOut>#

Conditional rendering helpers:

<SignedIn><UserMenu /></SignedIn>
<SignedOut><SignInButton /></SignedOut>
tsx

<GuestBanner />#

A small inline banner prompting sign-in for anonymous visitors.

Functions#

signIn / signOut#

Re-exports from Better Auth.

import { signOut } from 'deepspace'
await signOut()
ts

getAuthToken(): Promise<string | null>#

Returns the current JWT, refreshing it from the auth worker if necessary. Attach to outbound fetch calls:

const r = await fetch('/api/premium', {
  headers: { Authorization: `Bearer ${await getAuthToken()}` },
})
ts

Under the hood it does POST /api/auth/token same-origin with credentials: 'include' - the app's worker (or the Vite proxy in dev) routes that to the auth worker, which trades the session cookie for a short-lived ES256 JWT. There is no cross-origin call and no client secret. The token is cached in module scope and refreshed ~30 s before its exp, so calling getAuthToken() on every request is cheap. Any non-OK response, or a body without a token, resolves to null rather than throwing - a signed-out browser is the ordinary case, so check for null instead of catching. Anything outside the browser (SSR, Node) also gets null.

clearAuthToken(): void#

Clears the cached JWT. Forces the next getAuthToken() call to fetch a fresh one. Useful in tests or after explicit session changes.

Patterns#

Auth-state checks#

const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) return <Skeleton />
return isSignedIn ? <App /> : <Landing />
tsx

Profile access#

const { user, isLoading } = useUser()
if (isLoading) return null
return user ? <Hi name={user.name} /> : <SignInPrompt />
tsx

Sign-out button#

import { signOut } from 'deepspace'

<button onClick={() => signOut()}>Sign out</button>
tsx

See also#