React in five lines.
Wrap your app in TillAuthProvider, call useSignIn, ship. The provider auto-refreshes tokens, catches OAuth fragments, and exposes a consistent session object.
Get an app ID
Sign in to the dashboard, open TillDev → TillAuth → All apps, click + New app. Pick a name and a slug (your hosted-login URL becomes <slug>.tilldev.app). Copy the public app ID — looks like tau_pub_….
Install the package
pnpm add @tillstack/auth-reactWrap your root
// app/layout.tsx (or wherever your root lives)
import { TillAuthProvider } from '@tillstack/auth-react'
export default function RootLayout({ children }) {
return (
<TillAuthProvider appId={process.env.NEXT_PUBLIC_TILLAUTH_APP_ID!}>
{children}
</TillAuthProvider>
)
}Set NEXT_PUBLIC_TILLAUTH_APP_ID in your env to your app's public ID. Build-time inline; client code reads it from the bundle.
Sign in
// app/login/page.tsx
'use client'
import { useState } from 'react'
import { useSignIn } from '@tillstack/auth-react'
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { signIn, pending, error } = useSignIn()
async function submit(e: React.FormEvent) {
e.preventDefault()
const r = await signIn(email, password)
if (r.ok) window.location.href = '/'
// MFA challenge? r.mfa_required + r.challenge_token are set.
}
return (
<form onSubmit={submit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button disabled={pending}>{pending ? 'Signing in…' : 'Sign in'}</button>
{error && <p>{error}</p>}
</form>
)
}That's it for password auth. If the user has MFA enrolled, signIn() returns { ok: false, mfa_required: true, challenge_token } — pass the challenge to useMfa().verify() with the user's TOTP code.
Read the session anywhere
// app/profile/page.tsx
'use client'
import { useSession } from '@tillstack/auth-react'
export default function Profile() {
const session = useSession()
if (!session) return <p>Not signed in.</p>
return <p>Hello, {session.user.email}.</p>
}useSession() returns { user, access_token } | null with full type safety. The provider auto-refreshes the access token 60s before expiry, so this stays current as long as the tab is open.
What's next
- Add passkeys — one extra hook, one CTA button.
- Wire Google + GitHub — per-app client IDs, no SDK changes.
- Verify JWTs on your server — Node, or Cloudflare Workers / edge (same package; the verifier is Web Crypto).
- Build account settings into your own UI — password, passkeys and 2FA management over the same API the hosted pages use.