Nest Authbeta

Guards

Components and HOCs that protect routes and UI.

The React layer ships five guard components and two HOCs. They're thin — under the hood they all call useAuthStatus, useHasRole, or useHasPermission and decide what to render.

<AuthGuard>

Renders children only when the user is authenticated.

<AuthGuard
  loadingFallback={<Spinner />}
  fallback={<RedirectToLogin />}
  onUnauthenticated={() => navigate('/login')}
>
  <Dashboard />
</AuthGuard>
PropDefault
loadingFallbacknull (renders nothing while auth is initializing)
fallbacknull (renders nothing when unauthenticated)
onUnauthenticated(none)

<GuestGuard>

The opposite of <AuthGuard>. Renders children only when the user is not authenticated. Use on /login, /signup to redirect already-logged-in users away.

<GuestGuard onAuthenticated={() => navigate('/')}>
  <SignInForm />
</GuestGuard>
PropTypeNotes
loadingFallbackReactNode?While auth state loads
fallbackReactNode?Rendered when authenticated (and no onAuthenticated)
onAuthenticated() => void?Called when authenticated — use for navigation
allowWhenAddingAccountboolean?Render children even when authenticated (default false)

Adding another account: set allowWhenAddingAccount to render the login form to a user who is already signed in — the Gmail-style "Add another account" flow. With it on, the onAuthenticated redirect and fallback are skipped, so the login form shows. Drive it from your own signal (e.g. a ?add=1 query param). For a ready-made wrapper, prefer <AddAccountGuard>.

const adding = new URLSearchParams(location.search).get('add') === '1';
 
<GuestGuard allowWhenAddingAccount={adding} onAuthenticated={() => navigate('/')}>
  <SignInForm />
</GuestGuard>

<AddAccountGuard>

A GuestGuard that also renders its children while an already-authenticated user is adding another account. A plain <GuestGuard> redirects every authenticated user away from the login form — which makes "Add another account" impossible, since the user is authenticated by definition. <AddAccountGuard> renders the login form when the app is in add-account mode, and otherwise behaves exactly like <GuestGuard> (redirect/fallback when authenticated).

import { AddAccountGuard } from '@ackplus/nest-auth-react';
 
// Dedicated /add-account route — always shows the login form:
<AddAccountGuard>
  <SignInForm onSuccess={(dto) => addAccount(dto)} />
</AddAccountGuard>
 
// Shared /login route — only bypass the guard when ?add=1:
const adding = new URLSearchParams(location.search).get('add') === '1';
<AddAccountGuard adding={adding} onAuthenticated={() => navigate('/dashboard')}>
  <SignInForm />
</AddAccountGuard>

AddAccountGuardProps extends GuestGuardProps (minus allowWhenAddingAccount) and adds:

PropTypeNotes
addingboolean?Whether the app is in add-account mode (default true). When true, the login form renders even if a user is already signed in.

Pair this with the switcher's addAccount from useAccountSwitcher. See the Multi-account login & switching recipe.

<RequireRole>

Render children only if the user has the required role(s).

<RequireRole role="admin" fallback={<NotAuthorized />}>
  <AdminPanel />
</RequireRole>
 
<RequireRole role={['admin', 'editor']}>           {/* ANY */}
  <EditButton />
</RequireRole>
 
<RequireRole role={['admin', 'owner']} matchAll>
  <DangerZone />
</RequireRole>
PropTypeNotes
rolestring | string[]Required role(s)
matchAllbooleanRequire ALL (default: ANY)
loadingFallbackReactNode?While auth state loads
fallbackReactNode?When the user lacks the role(s)
onAccessDenied() => void?Called when fallback renders

<RequirePermission>

Same shape as <RequireRole> but for permissions.

<RequirePermission permission="orders.read">
  <OrdersTable />
</RequirePermission>
 
<RequirePermission permission={['orders.read', 'orders.write']} matchAll>
  <OrderEditor />
</RequirePermission>

HOCs

For class components or higher-order composition:

withRequireRole(Component, options)

const AdminOnlyDashboard = withRequireRole(Dashboard, {
  role: 'admin',
  FallbackComponent: NotAuthorized,
  LoadingComponent: Spinner,
});

The wrapped component receives extra props: { hasRole, isLoading, isAuthenticated }.

withRequirePermission(Component, options)

Same idea, for permissions.

Factory builders

If you reuse the same role/permission policy in many places, build a partially-applied HOC:

import { createRequireRoleHOC } from '@ackplus/nest-auth-react';
 
const requireAdmin = createRequireRoleHOC({
  role: 'admin',
  FallbackComponent: NotAuthorized,
});
 
const AdminPanel  = requireAdmin(AdminPanelImpl);
const AdminUsers  = requireAdmin(AdminUsersImpl);

Same pattern for createRequirePermissionHOC.

Combining guards

Nest them — they compose cleanly:

<AuthGuard fallback={<RedirectToLogin />}>
  <RequireRole role="admin" fallback={<NotAuthorized />}>
    <AdminDashboard />
  </RequireRole>
</AuthGuard>

For very common combos (auth + role), wrap in a custom component to keep your route file tidy.

Guard renders a blank page for a signed-in user

If a guard renders nothing for a user you know is authenticated, the usual cause is @ackplus/nest-auth-react being installed twice — common in pnpm/monorepos when a peer-React version split double-installs it. Each copy used to create its own React context, so <AuthProvider> from one copy populated one context while the hooks inside your guard read a different, still-default one: isLoading never flipped to false, and the guard sat rendering its (absent) loading UI forever.

Since 2.7.3 the contexts are cross-realm singletons pinned on globalThis, so duplicate copies share one context and the app works anyway. A guard that renders nothing purely because auth is still loading also emits a one-time dev-only console.warn pointing at this cause (production builds stay silent).

The real fix is still to dedupe the package to a single copy:

pnpm why @ackplus/nest-auth-react

Then align the react / react-dom peer ranges (or add a workspace overrides / resolutions entry) so only one copy is installed. Passing an explicit loadingComponent to your guards also makes a stuck-loading state visible instead of blank.

On this page