React client
WIP<AuthProvider>, authUseSession, authUseSignIn, authUseSignOut, authUseBeginProvider. React 16.8+.
Provider
import { AuthProvider } from '@gentleduck/auth/client/react'
export function App({ children }: { children: React.ReactNode }) {
return (
<AuthProvider baseUrl="/auth">
{children}
</AuthProvider>
)
}
Props:
| Prop | Purpose |
|---|---|
baseUrl | Default /auth. |
client | Pre-built vanilla client (advanced - for sharing across React + non-React). |
noInitialFetch | Skip the GET /auth/session call on mount. Useful for SSR where you already have the initial state. |
initialState | Hydrate from a server-rendered snapshot. Pair with noInitialFetch: true. |
fetch | Override the fetch implementation. |
authUseSession
import { authUseSession } from '@gentleduck/auth/client/react'
function Header() {
const { data, status } = authUseSession()
if (status === 'loading') return <Skeleton />
if (status === 'guest') return <SignInButton />
return <UserMenu identity={data.identity} />
}
Returns:
type UseSessionResult<Profile> =
| { status: 'loading'; data: null }
| { status: 'guest'; data: null }
| { status: 'authed'; data: { session, identity: { profile: Profile, ... } } }
Use the discriminated union to drive your render branches - data is
typed correctly in each arm.
Generic over Profile:
type Profile = { displayName: string; avatarUrl?: string }
const { data, status } = authUseSession<Profile>()
// data?.identity.profile.displayName <- typed
authUseSignIn
import { authUseSignIn } from '@gentleduck/auth/client/react'
function SignInForm() {
const signIn = authUseSignIn()
return (
<form
onSubmit={async (event) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
try {
await signIn.mutateAsync({
providerId: 'password',
input: {
email: formData.get('email') as string,
password: formData.get('password') as string,
},
})
} catch {
// error is also on signIn.error
}
}}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit" disabled={signIn.isLoading}>
{signIn.isLoading ? 'Signing in...' : 'Sign in'}
</button>
{signIn.error && <p>{signIn.error.code}</p>}
</form>
)
}
API:
| Field | Description |
|---|---|
mutate(args) | Fire-and-forget version. Errors go on signIn.error. |
mutateAsync(args) | Returns the result or throws. |
isLoading | True while the request is in flight. |
error | The AuthError from the last failure (cleared on next call). |
data | The { session, identity } from the last success. |
reset() | Clear error and data. |
authUseSignOut
import { authUseSignOut } from '@gentleduck/auth/client/react'
function SignOutButton() {
const signOut = authUseSignOut()
return (
<button onClick={() => signOut.mutate()} disabled={signOut.isLoading}>
Sign out
</button>
)
}
authUseBeginProvider
import { authUseBeginProvider } from '@gentleduck/auth/client/react'
function GoogleSignIn() {
const begin = authUseBeginProvider()
return (
<button onClick={() => begin.mutate({ providerId: 'google', input: {} })}>
Sign in with Google
</button>
)
}
function MagicLinkRequest() {
const begin = authUseBeginProvider()
return (
<form
onSubmit={async (e) => {
e.preventDefault()
await begin.mutateAsync({
providerId: 'magic-link',
input: { email: e.currentTarget.email.value, channel: 'email' },
})
}}>
<input name="email" type="email" required />
<button>Email me a link</button>
</form>
)
}
SSR (Next.js)
app/providers.tsx
'use client'
import { AuthProvider } from '@gentleduck/auth/client/react'
export function Providers({
children,
initialState,
}: {
children: React.ReactNode
initialState: any
}) {
return (
<AuthProvider baseUrl="/api/auth" noInitialFetch initialState={initialState}>
{children}
</AuthProvider>
)
}
app/layout.tsx
import { headers } from 'next/headers'
import { auth } from '~/lib/auth'
import { Providers } from './providers'
export default async function RootLayout({ children }) {
const ctx = await auth.resolveSession({ headers: new Headers(headers()) })
const initialState = ctx
? { session: ctx.session, identity: ctx.identity }
: null
return (
<html>
<body>
<Providers initialState={initialState}>{children}</Providers>
</body>
</html>
)
}
Now the first paint is already authenticated - no flash of guest UI.
Storybook decorator
import { authWithStorybook } from '@gentleduck/auth/client/react/storybook'
export const MyStory = {
decorators: [authWithStorybook({ baseUrl: '/auth' })],
render: () => <SignInButton />,
}
withAuth wraps the story in an <AuthProvider> backed by a mock
client, so your auth-aware components render without a real server.
Errors
The error field on authUseSignIn / authUseSignOut / authUseBeginProvider is
a typed AuthError. Branch on error.code:
{signIn.error?.code === 'AUTH/INVALID_CREDENTIALS' && <p>Wrong email or password</p>}
{signIn.error?.code === 'AUTH/MFA_REQUIRED' && <MfaForm />}
{signIn.error?.code === 'AUTH/RATE_LIMITED' && <p>Too many attempts; try again in a minute.</p>}