better-auth-ui
componentauthsource: ok · refreshed
Auth
A unified authentication component that renders the appropriate auth view based on path or view prop.
npx shadcn add "https://better-auth-ui.com/r/{style}/auth.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
alertbadgebuttoncalendarcardcheckboxcomboboxfieldhttps://better-auth-ui.com/r/radix-nova/auth-provider.jsonhttps://better-auth-ui.com/r/radix-nova/auth-redirect.jsonhttps://better-auth-ui.com/r/radix-nova/forgot-password.jsonhttps://better-auth-ui.com/r/radix-nova/reset-password.jsonhttps://better-auth-ui.com/r/radix-nova/sign-in.jsonhttps://better-auth-ui.com/r/radix-nova/sign-out.jsonhttps://better-auth-ui.com/r/radix-nova/sign-up.jsonhttps://better-auth-ui.com/r/radix-nova/verify-email.jsoninputinput-grouppopoverselectslidersonnerspinnerswitchtextareatooltip
Files21 · source cached 10/8/2026, 6:18:29 AM
examples/start-shadcn-example/src/components/auth/additional-field.tsx
"use client"
import {
type AdditionalField as AdditionalFieldConfig,
type AdditionalFieldFormValue,
getFormFieldErrors,
resolveInputType
} from "@better-auth-ui/core"
import { useAuth, useCopyToClipboard } from "@better-auth-ui/react"
import { format } from "date-fns"
import { CalendarIcon, Check, ChevronDownIcon, Copy } from "lucide-react"
import { type ComponentType, useRef, useState } from "react"
import { toast } from "sonner"
import { buttonVariants } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Checkbox } from "@/components/ui/checkbox"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList
} from "@/components/ui/combobox"
import {
Field,
FieldContent,
FieldError,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "cn"
export type AdditionalFieldProps = {
name: string
field: AdditionalFieldConfig
value: AdditionalFieldFormValue
onBlur: () => void
onChange: (value: AdditionalFieldFormValue) => void
isInvalid?: boolean
errors?: unknown[]
isPending?: boolean
/** Complete suffix appended to labels for fields that are not required. */
optionalLabel?: string
}
function valueToString(value: AdditionalFieldFormValue) {
if (value == null) return ""
return value instanceof Date ? value.toISOString() : String(value)
}
/** Convert a `defaultValue` into a `Date` for the calendar. */
function toDate(value: unknown): Date | undefined {
if (value instanceof Date) return value
if (typeof value === "string") {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
return undefined
}
/** Format a Date as `HH:mm:ss` for an `<input type="time">`. */
function formatTime(date: Date) {
const pad = (n: number) => n.toString().padStart(2, "0")
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/**
* Icon-only copy button used as an `InputGroupAddon`. `getValue` is invoked
* lazily on click so the button copies the input's *live* value rather than a
* stale snapshot — important when paired with editable inputs.
*/
function CopyButton({
getValue,
isDisabled
}: {
getValue: () => string | undefined
isDisabled?: boolean
}) {
const { localization } = useAuth()
const { copied, copy } = useCopyToClipboard({
onError: (error) => {
console.error("[Better Auth UI] Copy failed", error)
toast.error(localization.errors.copyFailed)
}
})
async function handleCopy() {
const value = getValue()
if (!value) return
await copy(value)
}
return (
<InputGroupButton
aria-label={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
title={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
onClick={handleCopy}
disabled={isDisabled}
>
{copied ? <Check /> : <Copy />}
</InputGroupButton>
)
}
/** Renders a single additional user field via shadcn primitives. */
export function AdditionalField({
name,
field: configuredField,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending,
optionalLabel
}: AdditionalFieldProps) {
const field =
optionalLabel && !configuredField.required
? {
...configuredField,
label: (
<>
{configuredField.label}
{optionalLabel}
</>
)
}
: configuredField
const inputType = resolveInputType(field)
const fieldErrors = getFormFieldErrors(errors ?? [])
if (field.render) {
const FieldRenderer = field.render as ComponentType<AdditionalFieldProps>
return (
<FieldRenderer
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
optionalLabel={optionalLabel}
/>
)
}
if (inputType === "hidden") {
return (
<input type="hidden" name={name} value={valueToString(value)} readOnly />
)
}
if (inputType === "textarea") {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Textarea
id={name}
name={name}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "number") {
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Input
id={name}
name={name}
type="number"
inputMode={maxFractionDigits ? "decimal" : "numeric"}
min={field.min}
max={field.max}
step={
field.step ??
(maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined)
}
value={typeof value === "number" ? value : ""}
onBlur={onBlur}
onChange={(event) =>
onChange(
event.target.value === "" ? null : event.target.valueAsNumber
)
}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "slider") {
return (
<SliderField
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
if (inputType === "switch") {
return (
<Field data-invalid={isInvalid} orientation="horizontal">
<Switch
id={name}
name={name}
checked={value === true}
onBlur={onBlur}
onCheckedChange={onChange}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldContent>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
</FieldContent>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "checkbox") {
return (
<Field data-invalid={isInvalid} orientation="horizontal">
<Checkbox
id={name}
name={name}
checked={value === true}
onBlur={onBlur}
onCheckedChange={(checked) => onChange(checked === true)}
required={field.required}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldContent>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
</FieldContent>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "select") {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Select
name={name}
value={valueToString(value) || undefined}
onValueChange={(nextValue) => onChange(nextValue)}
required={field.required}
disabled={isPending || field.readOnly}
>
<SelectTrigger
id={name}
className="w-full"
onBlur={onBlur}
aria-invalid={isInvalid}
>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "combobox") {
const selectedOption = field.options?.find(
(option) => option.value === valueToString(value)
)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Combobox
items={field.options ?? []}
name={name}
value={selectedOption ?? null}
onValueChange={(option) => onChange(option?.value ?? null)}
required={field.required}
disabled={isPending || field.readOnly}
>
<ComboboxInput
placeholder={field.placeholder}
id={name}
onBlur={onBlur}
aria-invalid={isInvalid}
/>
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(option) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "date" || inputType === "datetime") {
return (
<DateInput
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
return (
<InputField
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
function InputField({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const fieldErrors = getFormFieldErrors(errors ?? [])
const hasPrefix = field.prefix != null
const hasSuffix = field.suffix != null || field.copyable
const isNumeric = field.type === "number"
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
const nativeInputType = isNumeric ? "number" : undefined
const nativeInputMode = isNumeric
? maxFractionDigits
? "decimal"
: "numeric"
: undefined
const nativeStep = maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined
if (hasPrefix || hasSuffix) {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<InputGroup>
{hasPrefix && (
<InputGroupAddon align="inline-start">
{field.prefix}
</InputGroupAddon>
)}
<InputGroupInput
ref={inputRef}
id={name}
name={name}
type={nativeInputType}
inputMode={nativeInputMode}
step={nativeStep}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
{field.copyable ? (
<InputGroupAddon align="inline-end">
<CopyButton
getValue={() => inputRef.current?.value}
isDisabled={isPending}
/>
</InputGroupAddon>
) : (
field.suffix != null && (
<InputGroupAddon align="inline-end">
{field.suffix}
</InputGroupAddon>
)
)}
</InputGroup>
<FieldError errors={fieldErrors} />
</Field>
)
}
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Input
id={name}
name={name}
type={nativeInputType}
inputMode={nativeInputMode}
step={nativeStep}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
/**
* Slider field. Radix Slider doesn't render the current value, so we render
* it next to the label and control the state to keep the displayed value in
* sync. The selected value is submitted via the underlying Radix `name` prop.
*/
function SliderField({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
const min = field.min ?? 0
const max = field.max ?? 100
const step =
field.step ?? (maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1)
const numericValue = typeof value === "number" ? value : min
const fieldErrors = getFormFieldErrors(errors ?? [])
const formatter = new Intl.NumberFormat(undefined, field.formatOptions)
return (
<Field data-invalid={isInvalid}>
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<span className="text-sm text-muted-foreground tabular-nums">
{formatter.format(numericValue)}
</span>
</div>
<Slider
id={name}
name={name}
value={[numericValue]}
onBlur={onBlur}
onValueChange={(nextValue) =>
onChange((Array.isArray(nextValue) ? nextValue[0] : nextValue) ?? min)
}
min={min}
max={max}
step={step}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
/**
* Date / datetime input. Composes `Popover` + `Calendar` for the date and
* (optionally) `<input type="time">` for the time. Submits the combined ISO
* value via a hidden `<input>` so it shows up in `FormData`.
*/
function DateInput({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const { localization } = useAuth()
const inputType = resolveInputType(field)
const isDateTime = inputType === "datetime"
const fieldErrors = getFormFieldErrors(errors ?? [])
const date = toDate(value)
const [time, setTime] = useState<string>(
isDateTime && date ? formatTime(date) : ""
)
const [open, setOpen] = useState(false)
// Compose the hidden form value: ISO date for "date", ISO datetime for
// "datetime" (date + time).
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={`${name}-date`}>{field.label}</FieldLabel>
<div className="relative flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
type="button"
id={`${name}-date`}
data-empty={!date}
aria-invalid={isInvalid}
aria-required={field.required}
onBlur={onBlur}
disabled={isPending || field.readOnly}
className={cn(
buttonVariants({ variant: "outline" }),
"flex-1 justify-between font-normal",
"data-[empty=true]:text-muted-foreground"
)}
>
{date ? format(date, "PPP") : <span>{field.placeholder}</span>}
{isDateTime ? <ChevronDownIcon /> : <CalendarIcon />}
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
defaultMonth={date}
captionLayout="dropdown"
onSelect={(value) => {
if (!value) {
onChange(null)
} else {
const nextValue = new Date(value)
if (isDateTime && time.trim()) {
const [hours = "0", minutes = "0", seconds = "0"] =
time.split(":")
nextValue.setHours(
Number(hours),
Number(minutes),
Number(seconds),
0
)
} else {
nextValue.setHours(0, 0, 0, 0)
}
onChange(nextValue)
}
if (!isDateTime) setOpen(false)
}}
/>
</PopoverContent>
</Popover>
{isDateTime && (
<Field className="w-32">
<FieldLabel htmlFor={`${name}-time`} className="sr-only">
{localization.settings.time}
</FieldLabel>
<Input
type="time"
id={`${name}-time`}
step="1"
value={time}
onChange={(event) => {
const nextTime = event.target.value
setTime(nextTime)
if (!date) return
const nextValue = new Date(date)
const [hours = "0", minutes = "0", seconds = "0"] =
nextTime.split(":")
nextValue.setHours(
Number(hours),
Number(minutes),
Number(seconds),
0
)
onChange(nextValue)
}}
disabled={isPending || field.readOnly}
className="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</Field>
)}
</div>
<FieldError errors={fieldErrors} />
</Field>
)
}
examples/start-shadcn-example/src/components/auth/auth-form.tsx
"use client"
import {
type AdditionalField as AdditionalFieldConfig,
type AdditionalFieldFormValue,
DEFAULT_ADDITIONAL_FIELD_VALIDATION_DEBOUNCE_MS,
getFormFieldErrors,
normalizeAuthFormServerError,
validateAdditionalFieldRequired,
validateAdditionalFieldValue
} from "@better-auth-ui/core"
import {
type AnyFormApi,
createFormHook,
createFormHookContexts
} from "@tanstack/react-form"
import {
type ComponentProps,
type FormEvent,
type ReactNode,
useRef
} from "react"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldError,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { AdditionalField, type AdditionalFieldProps } from "./additional-field"
const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts()
const DEFAULT_AUTH_FORM_SERVER_ERROR = "Unable to submit this form. Try again."
export function focusFirstInvalidAuthFormControl(form: HTMLFormElement) {
requestAnimationFrame(() => {
form
.querySelector<HTMLElement>(
'[aria-invalid="true"]:not([disabled]), :invalid:not([disabled])'
)
?.focus()
})
}
function AuthFormFieldError() {
const field = useFieldContext<unknown>()
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
if (!isInvalid) return null
const errors = getFormFieldErrors(field.state.meta.errors)
return errors.length > 0 ? <FieldError errors={errors} /> : null
}
function AuthFormServerError() {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.errorMap.onServer}>
{(error) => {
const formError =
error && typeof error === "object" && "form" in error
? error.form
: error
const errors = getFormFieldErrors(formError ? [formError] : [])
return errors.length > 0 ? <FieldError errors={errors} /> : null
}}
</form.Subscribe>
)
}
export function setAuthFormServerError(
form: AnyFormApi,
error: unknown,
fallbackMessage: string
) {
const normalized = normalizeAuthFormServerError(error, fallbackMessage)
form.setErrorMap({
onServer: {
fields: normalized.fields ?? {},
form: normalized.form
}
})
}
export function clearAuthFormServerError(form: AnyFormApi) {
form.setErrorMap({ onServer: { fields: {} } })
}
export function clearAuthFormFieldServerError(
form: AnyFormApi,
fieldName: string
) {
form.setErrorMap({ onServer: undefined })
if (!fieldName) return
const fieldMeta = form.getFieldMeta(fieldName as never)
if (!fieldMeta?.errorMap.onServer) return
form.setFieldMeta(fieldName as never, (current = fieldMeta) => ({
...current,
errorMap: { ...current.errorMap, onServer: undefined },
errorSourceMap: { ...current.errorSourceMap, onServer: undefined }
}))
}
export async function runAuthFormAction(
form: AnyFormApi,
action: () => Promise<unknown>,
serverErrorMessage = DEFAULT_AUTH_FORM_SERVER_ERROR
) {
clearAuthFormServerError(form)
try {
await action()
return true
} catch (error) {
if (!form.state.errorMap.onServer) {
setAuthFormServerError(form, error, serverErrorMessage)
}
return false
}
}
export async function submitAuthForm(
form: AnyFormApi,
serverErrorMessage = DEFAULT_AUTH_FORM_SERVER_ERROR
) {
clearAuthFormServerError(form)
try {
await form.handleSubmit()
return form.state.isValid
} catch (error) {
if (!form.state.errorMap.onServer) {
setAuthFormServerError(form, error, serverErrorMessage)
}
return false
}
}
type AuthFormRootProps = Omit<ComponentProps<"form">, "onSubmit"> & {
onBeforeSubmit?: () => void
serverErrorMessage?: string
}
function AuthFormRoot({
children,
onBeforeSubmit,
onInput,
serverErrorMessage = DEFAULT_AUTH_FORM_SERVER_ERROR,
...props
}: AuthFormRootProps) {
const form = useFormContext()
const submittingRef = useRef(false)
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (submittingRef.current || form.state.isSubmitting) return
const formElement = event.currentTarget
onBeforeSubmit?.()
submittingRef.current = true
try {
const isValid = await submitAuthForm(form, serverErrorMessage)
if (!isValid) focusFirstInvalidAuthFormControl(formElement)
} finally {
submittingRef.current = false
}
}
return (
<form
{...props}
onInvalid={(event) =>
focusFirstInvalidAuthFormControl(event.currentTarget)
}
onInput={(event) => {
const target = event.target
const fieldName =
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement
? target.name
: ""
clearAuthFormFieldServerError(form, fieldName)
onInput?.(event)
}}
onSubmit={submit}
>
{children}
</form>
)
}
type AuthFormTextFieldProps = Omit<
ComponentProps<typeof Input>,
"name" | "onBlur" | "onChange" | "value"
> & {
description?: ReactNode
label: ReactNode
}
function AuthFormTextField({
description,
id,
label,
...props
}: AuthFormTextFieldProps) {
const field = useFieldContext<string>()
const form = useFormContext()
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
const inputId = id ?? field.name
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={inputId}>{label}</FieldLabel>
<Input
{...props}
aria-busy={field.state.meta.isValidating || undefined}
aria-invalid={isInvalid}
id={inputId}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => {
clearAuthFormFieldServerError(form, field.name)
field.handleChange(event.target.value)
}}
value={field.state.value}
/>
{description ? <FieldDescription>{description}</FieldDescription> : null}
<AuthFormFieldError />
</Field>
)
}
function AuthFormSubmitButton({
children,
disabled,
isPending,
...props
}: ComponentProps<typeof Button> & { isPending?: boolean }) {
const form = useFormContext()
return (
<form.Subscribe
selector={(state) => [state.isSubmitting, state.isValidating] as const}
>
{([isSubmitting, isValidating]) => (
<Button
{...props}
aria-busy={isPending || isSubmitting || undefined}
aria-disabled={
disabled || isPending || isSubmitting || isValidating || undefined
}
disabled={disabled || isPending || isSubmitting || isValidating}
type="submit"
>
{isPending || isSubmitting ? (
<Spinner data-icon="inline-start" />
) : null}
{children}
</Button>
)}
</form.Subscribe>
)
}
type AuthFormAdditionalFieldProps = Omit<
AdditionalFieldProps,
"errors" | "isInvalid" | "name" | "onBlur" | "onChange" | "value"
>
function AuthFormAdditionalField(props: AuthFormAdditionalFieldProps) {
const field = useFieldContext<AdditionalFieldFormValue>()
const form = useFormContext()
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<AdditionalField
{...props}
errors={
isInvalid ? getFormFieldErrors(field.state.meta.errors) : undefined
}
isInvalid={isInvalid}
name={field.name}
onBlur={field.handleBlur}
onChange={(value) => {
clearAuthFormFieldServerError(form, field.name)
field.handleChange(value)
}}
value={field.state.value}
/>
)
}
export const {
useAppForm: useAuthForm,
withFieldGroup: withAuthFieldGroup,
withForm: withAuthForm
} = createFormHook({
fieldComponents: {
AuthFormAdditionalField,
AuthFormFieldError,
AuthFormTextField
},
fieldContext,
formComponents: {
AuthFormRoot,
AuthFormServerError,
AuthFormSubmitButton
},
formContext
})
export function isAuthFormFieldInvalid({
isTouched,
isValid
}: {
isTouched: boolean
isValid: boolean
}) {
return isTouched && !isValid
}
export function getAuthAdditionalFieldValidators(
field: AdditionalFieldConfig,
requiredMessage: string
) {
return {
onChange: ({ value }: { value: AdditionalFieldFormValue }) =>
validateAdditionalFieldRequired(field, value, requiredMessage),
onChangeAsync: field.validate
? ({ value }: { value: AdditionalFieldFormValue }) =>
validateAdditionalFieldValue(field, value)
: undefined,
onChangeAsyncDebounceMs: field.validate
? (field.validateDebounceMs ??
DEFAULT_ADDITIONAL_FIELD_VALIDATION_DEBOUNCE_MS)
: undefined
}
}
examples/start-shadcn-example/src/components/auth/auth-redirect.tsx
"use client"
import { getAuthRedirectAction } from "@better-auth-ui/core"
import { useAuth, useSession } from "@better-auth-ui/react"
import { useEffect, useRef } from "react"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
export type AuthRedirectProps = {
className?: string
}
/**
* Redirects authenticated users to a validated same-origin target.
*
* Signed-out users are sent through the sign-in view first. The redirect view
* is preserved as the post-authentication destination so API callbacks receive
* a full-page request after the session is established.
*
* @param className - Optional additional class names applied to the spinner
* @returns The centered spinner shown while the session and redirect resolve
*/
export function AuthRedirect({ className }: AuthRedirectProps) {
const { authClient, basePaths, viewPaths } = useAuth()
const { data: session, isPending } = useSession(authClient)
const hasRedirected = useRef(false)
useEffect(() => {
if (isPending || hasRedirected.current) return
hasRedirected.current = true
const action = getAuthRedirectAction(
new URL(window.location.href),
Boolean(session),
`${basePaths.auth}/${viewPaths.auth.signIn}`
)
window.location.replace(action.to)
}, [basePaths.auth, isPending, session, viewPaths.auth.signIn])
return <Spinner className={cn("mx-auto my-auto", className)} />
}
examples/start-shadcn-example/src/components/auth/auth-result.tsx
"use client"
import {
type AuthResult,
getAuthResultMessage,
parseAuthResult
} from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { CircleCheckIcon, CircleXIcon, TriangleAlertIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import { cn } from "cn"
type AuthResultProps = {
className?: string
fallbackIntent: "danger" | "success"
}
function AuthResultView({ className, fallbackIntent }: AuthResultProps) {
const { basePaths, localization, navigate, viewPaths } = useAuth()
const [result, setResult] = useState<AuthResult>(() =>
parseAuthResult("", fallbackIntent)
)
useEffect(() => {
setResult(parseAuthResult(window.location.search, fallbackIntent))
}, [fallbackIntent])
const message = getAuthResultMessage(result, localization)
const action = (() => {
switch (result.action) {
case "accountSettings":
return {
label: localization.auth.callbackViewAccountSettings,
to: `${basePaths.settings}/${viewPaths.settings.security}`
}
case "continue":
return {
label: localization.auth.callbackContinue,
to: result.redirectTo ?? "/"
}
case "forgotPassword":
return {
label: localization.auth.forgotPassword,
to: `${basePaths.auth}/${viewPaths.auth.forgotPassword}`
}
case "signUp":
return {
label: localization.auth.signUp,
to: `${basePaths.auth}/${viewPaths.auth.signUp}`
}
case "verifyEmail":
return {
label: localization.auth.verifyEmail,
to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
}
default:
return {
label: localization.auth.signIn,
to: `${basePaths.auth}/${viewPaths.auth.signIn}`
}
}
})()
const Icon =
result.intent === "success"
? CircleCheckIcon
: result.intent === "warning"
? TriangleAlertIcon
: CircleXIcon
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader className="justify-items-center text-center">
<Icon
aria-hidden="true"
className={cn(
"mb-1 size-10",
result.intent === "success"
? "text-primary"
: result.intent === "warning"
? "text-amber-600 dark:text-amber-400"
: "text-destructive"
)}
/>
<CardTitle className="text-xl">{message.title}</CardTitle>
<CardDescription>{message.description}</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full" onClick={() => navigate({ to: action.to })}>
{action.label}
</Button>
</CardContent>
</Card>
)
}
export function AuthCallback(props: Omit<AuthResultProps, "fallbackIntent">) {
return <AuthResultView {...props} fallbackIntent="success" />
}
export function AuthError(props: Omit<AuthResultProps, "fallbackIntent">) {
return <AuthResultView {...props} fallbackIntent="danger" />
}
examples/start-shadcn-example/src/components/auth/auth.tsx
"use client"
import type { AuthView } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { type ComponentType, useEffect } from "react"
import { AuthRedirect } from "./auth-redirect"
import { AuthCallback, AuthError } from "./auth-result"
import { ForgotPassword } from "./forgot-password"
import type { SocialLayout } from "./provider-buttons"
import { ResetLinkSent } from "./reset-link-sent"
import { ResetPassword } from "./reset-password"
import { SignIn } from "./sign-in"
import { SignOut } from "./sign-out"
import { SignUp } from "./sign-up"
import { VerifyEmail } from "./verify-email"
export type AuthProps = {
className?: string
path?: string
socialLayout?: SocialLayout
socialPosition?: "top" | "bottom"
/** @remarks `AuthView` */
view?: AuthView
}
/**
* Built-in views that only make sense when email + password auth is enabled.
* When it's disabled, the `<Auth>` router redirects these to `signIn` so a
* plugin's `fallbackViews.auth.signIn` (e.g. magic link) takes over.
*/
const PASSWORD_ONLY_VIEWS = [
"signUp",
"forgotPassword",
"resetPassword",
"resetLinkSent"
]
const AUTH_VIEWS: Partial<Record<AuthView, ComponentType<AuthProps>>> = {
callback: AuthCallback,
error: AuthError,
redirect: AuthRedirect,
signIn: SignIn,
signOut: SignOut,
signUp: SignUp,
forgotPassword: ForgotPassword,
resetPassword: ResetPassword,
resetLinkSent: ResetLinkSent,
verifyEmail: VerifyEmail
}
/**
* Render the appropriate authentication view based on the provided `view` or `path`.
*
* Resolution order:
* 1. Plugin overrides (`plugin.views.auth[currentView]`) — first registered wins.
* 2. Plugin fallbacks (`plugin.fallbackViews.auth.signIn`) when password auth is off.
* 3. Built-in views.
*
* @param path - Route path used to resolve an auth view when `view` is not provided
* @param socialLayout - Social layout to apply to sign-in/sign-up/magic-link views
* @param socialPosition - Position for social buttons (`"top"` or `"bottom"`)
* @param view - Explicit auth view to render (e.g., `"signIn"`, `"signUp"`)
* @returns The React element for the resolved authentication view
*/
export function Auth({
className,
path,
socialLayout,
socialPosition,
view
}: AuthProps) {
const { basePaths, emailAndPassword, plugins, viewPaths, navigate } =
useAuth()
if (!view && !path) {
throw new Error("[Better Auth UI] Either `view` or `path` must be provided")
}
const authView =
view ||
(Object.keys(viewPaths.auth) as AuthView[]).find(
(key) => viewPaths.auth[key] === path
)
// When email + password auth is disabled, password-only views (signUp,
// forgotPassword, resetPassword) have no meaning. Redirect them to signIn,
// where a plugin's `fallbackViews.auth.signIn` (e.g. magic link) takes
// over as the primary entry point.
const shouldRedirectToSignIn =
!emailAndPassword?.enabled &&
authView &&
PASSWORD_ONLY_VIEWS.includes(authView)
useEffect(() => {
if (shouldRedirectToSignIn) {
navigate({
to: `${basePaths.auth}/${viewPaths.auth.signIn}`,
replace: true
})
}
}, [shouldRedirectToSignIn, navigate, basePaths.auth, viewPaths.auth.signIn])
if (shouldRedirectToSignIn) {
return null
}
// 1. Plugin overrides (`views.auth[currentView]`) — first plugin wins,
// including over built-in views. Resolves the view key from `view`,
// then `authView` (built-in path match), then plugin-introduced paths
// (e.g. `magicLink` → `/auth/magic-link`).
for (const plugin of plugins) {
const pluginAuthPaths = plugin.viewPaths?.auth
const pluginView =
view ??
authView ??
(pluginAuthPaths &&
Object.keys(pluginAuthPaths).find(
(key) => pluginAuthPaths[key] === path
))
if (!pluginView) continue
const PluginView = plugin.views?.auth?.[pluginView]
if (!PluginView) continue
return (
<PluginView
className={className}
socialLayout={socialLayout}
socialPosition={socialPosition}
/>
)
}
// 2. Plugin fallbacks — only when the built-in `signIn` isn't viable
// (password auth is off). Used by `magicLinkPlugin` to render the
// magic-link form as the primary passwordless sign-in surface.
if (authView === "signIn" && !emailAndPassword?.enabled) {
const Fallback = plugins.find(
(plugin) => plugin.fallbackViews?.auth?.signIn
)?.fallbackViews?.auth?.signIn
if (Fallback) {
return (
<Fallback
className={className}
socialLayout={socialLayout}
socialPosition={socialPosition}
/>
)
}
}
const AuthView = authView ? AUTH_VIEWS[authView] : undefined
if (!AuthView) {
throw new Error(
`[Better Auth UI] Unknown view "${authView}". Valid views are: ${Object.keys(AUTH_VIEWS).join(", ")}`
)
}
return (
<AuthView
className={className}
socialLayout={socialLayout}
socialPosition={socialPosition}
/>
)
}
examples/start-shadcn-example/src/components/auth/forgot-password.tsx
"use client"
import { getViewURL, validateEmailAddress } from "@better-auth-ui/core"
import {
useAuth,
useFetchOptions,
useRequestPasswordReset
} from "@better-auth-ui/react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "./auth-form"
import { RESET_LINK_SENT_STORAGE_KEY } from "./reset-link-sent"
export type ForgotPasswordProps = {
className?: string
}
/**
* Render a card-based "Forgot Password" form that sends a password-reset email.
*
* The form displays an email input, submit button, and a link back to sign-in.
* After a successful request the submitted email is stored in `sessionStorage`
* and the user is redirected to the reset-link-sent view, which offers to open
* their email provider.
*
* @param className - Optional additional CSS class names applied to the card
* @returns The forgot-password form UI as a JSX element
*/
export function ForgotPassword({ className }: ForgotPasswordProps) {
const {
authClient,
baseURL,
basePaths,
localization,
navigate,
plugins,
viewPaths,
Link
} = useAuth()
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const { mutateAsync: requestPasswordReset, isPending } =
useRequestPasswordReset(authClient, {
onError: () => {
resetFetchOptions()
},
onSuccess: (_data, { email }) => {
sessionStorage.setItem(RESET_LINK_SENT_STORAGE_KEY, email)
navigate({ to: `${basePaths.auth}/${viewPaths.auth.resetLinkSent}` })
}
})
const form = useAuthForm({
defaultValues: { email: "" },
onSubmit: async ({ value }) =>
await requestPasswordReset({
email: value.email,
redirectTo: getViewURL(
baseURL,
basePaths.auth,
viewPaths.auth.resetPassword
),
fetchOptions
})
})
const Captcha = plugins.find(
(plugin) => plugin.captchaComponent
)?.captchaComponent
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.forgotPassword}
</CardTitle>
</CardHeader>
<CardContent>
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
<form.AppField
name="email"
validators={{
onChange: ({ value }) =>
validateEmailAddress(value, {
invalidMessage: localization.auth.invalidEmail,
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="email">
{localization.auth.email}
</FieldLabel>
<Input
id="email"
name={field.name}
type="email"
autoComplete="email"
placeholder={localization.auth.emailPlaceholder}
required
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{Captcha && <div className="flex justify-center">{Captcha}</div>}
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton
isPending={isPending}
disabled={isPending}
>
{localization.auth.sendResetLink}
</form.AuthFormSubmitButton>
</div>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.rememberYourPassword}{" "}
<Link
href={`${basePaths.auth}/${viewPaths.auth.signIn}`}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/last-login-method/last-used-badge.tsx
"use client"
import { useLastLoginMethod } from "@better-auth-ui/react"
import { Badge } from "@/components/ui/badge"
export type LastUsedBadgeProps = {
/** Login method IDs that should display the indicator. */
method: string | string[]
/** Use the shorter label in constrained layouts. */
compact?: boolean
/** Float the compact indicator over the top-right edge of its container. */
floating?: boolean
}
/**
* Displays an indicator when one of the supplied method IDs matches Better
* Auth's stored last login method.
*/
export function LastUsedBadge({
method,
compact,
floating
}: LastUsedBadgeProps) {
const { method: lastLoginMethod, localization } = useLastLoginMethod()
const methods = Array.isArray(method) ? method : [method]
if (!lastLoginMethod || !methods.includes(lastLoginMethod)) return null
return (
<Badge
className={
floating
? "pointer-events-none absolute top-0 right-0 z-10 translate-x-1/4 -translate-y-1/2 shadow-sm"
: undefined
}
variant="secondary"
>
{compact || floating ? localization.lastUsedShort : localization.lastUsed}
</Badge>
)
}
examples/start-shadcn-example/src/components/auth/open-email-button.tsx
"use client"
import { createQrCodeSvgData, getEmailProviderLink } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import type { VariantProps } from "class-variance-authority"
import { QrCode } from "lucide-react"
import { useMemo } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
import { cn } from "cn"
export type OpenEmailButtonProps = {
/** Email address used to detect the provider, e.g. from the verify-email flow. */
email: string
className?: string
/**
* Button variant. Defaults to the primary style for dead-end views where
* opening the inbox is the only action; pass `"secondary"` where it sits
* beside a submit button that should stay the primary call to action.
*/
variant?: VariantProps<typeof buttonVariants>["variant"]
}
/**
* Render a button that opens the user's email provider login page in a new
* tab. Hovering or focusing the button reveals a QR code for opening the same
* provider on another device.
*
* The provider is resolved from the email domain via the curated
* `@mikkelscheike/email-provider-links` dataset (Gmail, Outlook, GMX, etc.).
* Renders nothing when the domain is empty or not a known provider.
*
* @param email - Email address to resolve the provider from.
* @param className - Additional CSS classes applied to the button.
* @param variant - Button variant. Defaults to the primary style.
* @returns The open-email button, or `null` when no provider matches.
*/
export function OpenEmailButton({
email,
className,
variant
}: OpenEmailButtonProps) {
const { localization } = useAuth()
const provider = getEmailProviderLink(email)
const loginUrl = provider?.loginUrl
const qrCode = useMemo(
() => (loginUrl ? createQrCodeSvgData(loginUrl) : null),
[loginUrl]
)
if (!provider || !qrCode) return null
const scanLabel = localization.auth.scanToOpenEmailProvider.replace(
"{{provider}}",
provider.companyProvider
)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
type="button"
className={cn(buttonVariants({ variant }), "w-full", className)}
onClick={() =>
window.open(provider.loginUrl, "_blank", "noopener,noreferrer")
}
>
{localization.auth.openEmailProvider.replace(
"{{provider}}",
provider.companyProvider
)}
<QrCode data-icon="inline-end" />
</TooltipTrigger>
<TooltipContent
sideOffset={8}
className="flex-col items-center gap-2 p-3"
>
<svg
viewBox={`0 0 ${qrCode.size} ${qrCode.size}`}
aria-hidden="true"
focusable="false"
className="size-40"
>
<path fill="white" d={`M0 0h${qrCode.size}v${qrCode.size}H0z`} />
<path fill="black" d={qrCode.path} shapeRendering="crispEdges" />
</svg>
<p className="max-w-40 text-center leading-snug">{scanLabel}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
examples/start-shadcn-example/src/components/auth/password-strength-meter.tsx
"use client"
import {
evaluatePasswordStrength,
type PasswordStrengthLevel
} from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { cn } from "cn"
/** Fixed segment identities, so the bars keep their own React keys. */
const STRENGTH_SEGMENTS = [1, 2, 3, 4] as const
type FilledLevel = Exclude<PasswordStrengthLevel, "empty">
const segmentColors: Record<FilledLevel, string> = {
weak: "bg-destructive",
fair: "bg-amber-500",
good: "bg-sky-500",
strong: "bg-emerald-500"
}
export type PasswordStrengthMeterProps = {
/** The password as typed. Renders nothing while it is empty. */
password: string
className?: string
}
/**
* Four-segment strength hint shown while someone picks a new password.
*
* Renders nothing when `emailAndPassword.strengthMeter` is off or the field is
* empty. The score never gates submission: your server rules stay the
* authority on what is acceptable.
*/
export function PasswordStrengthMeter({
password,
className
}: PasswordStrengthMeterProps) {
const { emailAndPassword, localization } = useAuth()
if (!emailAndPassword?.strengthMeter) return null
const { score, level } = evaluatePasswordStrength(password, {
minLength: emailAndPassword.minPasswordLength
})
if (level === "empty") return null
const levelLabels: Record<FilledLevel, string> = {
weak: localization.auth.passwordWeak,
fair: localization.auth.passwordFair,
good: localization.auth.passwordGood,
strong: localization.auth.passwordStrong
}
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{/* Decorative: the live region below is what gets announced. */}
<div aria-hidden="true" className="flex gap-1">
{STRENGTH_SEGMENTS.map((segment) => (
<span
key={segment}
className={cn(
"h-1 flex-1 rounded-full bg-muted transition-colors",
segment <= score && segmentColors[level]
)}
/>
))}
</div>
<p aria-live="polite" className="text-muted-foreground text-xs">
{localization.auth.passwordStrength}:{" "}
<span className="font-medium text-foreground">
{levelLabels[level]}
</span>
</p>
</div>
)
}
examples/start-shadcn-example/src/components/auth/provider-button.tsx
"use client"
import {
type AuthSocialProvider,
type AuthView,
authMutationKeys,
getProviderId,
getProviderName,
type OAuthPopupAuthClient
} from "@better-auth-ui/core"
import {
renderProviderIcon,
useAuth,
useFetchOptions,
useSignInOAuthPopup,
useSignInSocial
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import type { ComponentProps } from "react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
import { LastUsedBadge } from "./last-login-method/last-used-badge"
export type ProviderButtonProps = {
provider: AuthSocialProvider
display?: "full" | "name" | "icon"
view?: AuthView
} & Omit<ComponentProps<typeof Button>, "onClick" | "children" | "disabled">
/**
* Social provider sign-in button.
*
* @param provider - Provider to sign in with.
* @param display - `"full"` (e.g. "Continue with Google"), `"name"` (just the provider name), or `"icon"` (icon only).
*/
export function ProviderButton({
provider,
display = "full",
view = "signIn",
variant = "outline",
className,
...props
}: ProviderButtonProps) {
const {
authClient,
baseURL,
localization,
navigate,
redirectTo,
socialSignInMode
} = useAuth()
const callbackURL = `${baseURL}${redirectTo}`
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const { mutate: signInSocial, isPending: signInSocialPending } =
useSignInSocial(authClient, { onError: resetFetchOptions })
const { mutate: signInPopup, isPending: signInPopupPending } =
useSignInOAuthPopup(authClient as OAuthPopupAuthClient, {
onError: resetFetchOptions
})
const providerId = getProviderId(provider)
const providerIcon = renderProviderIcon(provider)
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const signUpMutating = useIsMutating({
mutationKey: authMutationKeys.signUp.all
})
const isPending = signInMutating + signUpMutating > 0
const handleSignIn = () => {
if (socialSignInMode === "popup") {
signInPopup(
{
provider: providerId,
callbackURL,
requestSignUp: view === "signUp"
},
{ onSuccess: () => navigate({ to: redirectTo }) }
)
return
}
signInSocial({ provider: providerId, callbackURL, fetchOptions })
}
return (
<Button
type="button"
variant={variant}
disabled={isPending}
onClick={handleSignIn}
className={cn("relative overflow-visible", className)}
{...props}
>
{signInSocialPending || signInPopupPending ? <Spinner /> : providerIcon}
{display === "full"
? localization.auth.continueWith.replace(
"{{provider}}",
getProviderName(provider)
)
: display === "name"
? getProviderName(provider)
: null}
{display === "icon" && (
<span className="sr-only">{getProviderName(provider)}</span>
)}
{view !== "signUp" && <LastUsedBadge method={providerId} floating />}
</Button>
)
}
examples/start-shadcn-example/src/components/auth/provider-buttons.tsx
"use client"
import { type AuthView, getProviderId } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { useMemo } from "react"
import { cn } from "cn"
import { ProviderButton } from "./provider-button"
export type ProviderButtonsProps = {
socialLayout?: SocialLayout
view?: AuthView
}
export type SocialLayout = "auto" | "horizontal" | "vertical" | "grid"
/**
* Render sign-in buttons for configured social providers. Each button owns its own sign-in mutation
* and reads the shared sign-in pending state from React Query.
*
* @param socialLayout - Preferred layout for the provider buttons; `"auto"` chooses based on the number of providers.
*/
export function ProviderButtons({
socialLayout = "auto",
view = "signIn"
}: ProviderButtonsProps) {
const { socialProviders } = useAuth()
const resolvedSocialLayout = useMemo(() => {
if (socialLayout === "auto") {
if (socialProviders?.length && socialProviders.length >= 4) {
return "horizontal"
}
return "vertical"
}
return socialLayout
}, [socialLayout, socialProviders?.length])
return (
<div
className={cn(
"gap-3",
resolvedSocialLayout === "grid" && "grid grid-cols-2",
resolvedSocialLayout === "vertical" && "flex flex-col",
resolvedSocialLayout === "horizontal" && "flex flex-row flex-wrap"
)}
>
{socialProviders?.map((provider) => (
<ProviderButton
key={getProviderId(provider)}
provider={provider}
view={view}
display={
resolvedSocialLayout === "vertical"
? "full"
: resolvedSocialLayout === "grid"
? "name"
: "icon"
}
className={cn(resolvedSocialLayout === "horizontal" && "flex-1")}
/>
))}
</div>
)
}
examples/start-shadcn-example/src/components/auth/reauthentication.tsx
"use client"
import {
getReauthenticationSignInURL,
isReauthenticationSignInURL
} from "@better-auth-ui/core"
import { useAuth, useSignOut } from "@better-auth-ui/react"
import { useSyncExternalStore } from "react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
const subscribeToLocation = () => () => undefined
function useIsReauthenticationSignIn() {
return useSyncExternalStore(
subscribeToLocation,
() => isReauthenticationSignInURL(new URL(window.location.href)),
() => false
)
}
export type ReauthenticationActionProps = {
className?: string
showTitle?: boolean
}
export function ReauthenticationAction({
className,
showTitle = true
}: ReauthenticationActionProps) {
const auth = useAuth()
const signOut = useSignOut(auth.authClient)
const handleReauthentication = () => {
const signInURL = getReauthenticationSignInURL(
new URL(window.location.href),
`${auth.basePaths.auth}/${auth.viewPaths.auth.signIn}`
)
signOut.mutate(undefined, {
onSuccess: () => auth.navigate({ to: signInURL })
})
}
return (
<div className={cn("flex flex-col items-start gap-3 p-4", className)}>
<div className="flex flex-col gap-1">
{showTitle ? (
<h3 className="text-sm font-medium">
{auth.localization.settings.reauthenticationTitle}
</h3>
) : null}
<p className="text-sm text-muted-foreground">
{auth.localization.settings.reauthenticationDescription}
</p>
</div>
<Button
disabled={signOut.isPending}
onClick={handleReauthentication}
size="sm"
>
{signOut.isPending ? <Spinner data-icon="inline-start" /> : null}
{auth.localization.settings.reauthenticationAction}
</Button>
</div>
)
}
export function ReauthenticationNotice() {
const auth = useAuth()
const isReauthenticationSignIn = useIsReauthenticationSignIn()
if (!isReauthenticationSignIn) return null
return (
<Alert className="mx-4 w-auto group-data-[size=sm]/card:mx-3">
<AlertTitle>
{auth.localization.settings.reauthenticationTitle}
</AlertTitle>
<AlertDescription>
{auth.localization.settings.reauthenticationDescription}
</AlertDescription>
</Alert>
)
}
examples/start-shadcn-example/src/components/auth/reset-link-sent.tsx
"use client"
import { getAuthLinkURL } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { cn } from "cn"
import { OpenEmailButton } from "./open-email-button"
import { useIsHydrated } from "./use-is-hydrated"
/** `sessionStorage` key the forgot-password form stores the submitted email under. */
export const RESET_LINK_SENT_STORAGE_KEY = "better-auth-ui.reset-link-sent"
export type ResetLinkSentProps = {
className?: string
}
/**
* Render a card confirming that a password-reset email was sent, with a
* button to open the user's email provider.
*
* The target email is read from `sessionStorage` (set when the forgot-password
* form redirects here); the OpenEmail button is only shown when an email is
* stored and resolves to a known provider.
*
* @param className - Additional CSS classes applied to the card
* @returns The reset-link-sent card React element
*/
export function ResetLinkSent({ className }: ResetLinkSentProps) {
const { basePaths, localization, redirectTo, viewPaths, Link } = useAuth()
const isHydrated = useIsHydrated()
const [email, setEmail] = useState(
(isHydrated && sessionStorage.getItem(RESET_LINK_SENT_STORAGE_KEY)) || ""
)
useEffect(() => {
setEmail(sessionStorage.getItem(RESET_LINK_SENT_STORAGE_KEY) ?? "")
}, [])
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.checkYourEmailTitle}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<FieldDescription>
{email
? localization.auth.resetLinkSentTo.replace("{{email}}", email)
: localization.auth.passwordResetEmailSent}
</FieldDescription>
{email && <OpenEmailButton email={email} />}
</div>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.rememberYourPassword}{" "}
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/reset-password.tsx
"use client"
import {
getAuthLinkURL,
isPasswordCompromisedError,
validateMatchingValue,
validateStringLength
} from "@better-auth-ui/core"
import { useAuth, useResetPassword } from "@better-auth-ui/react"
import { Eye, EyeOff } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "./auth-form"
import { PasswordStrengthMeter } from "./password-strength-meter"
export type ResetPasswordProps = {
className?: string
}
/**
* Render a password reset form that validates the reset token from the URL, accepts a new password (and optional confirmation), and submits it to the auth client.
*
* The component checks for a `token` query parameter on mount and, if missing, shows an error toast and navigates to the sign-in page. It exposes per-field validation messages, toggles for password visibility, and disables inputs while the reset request is pending.
*
* @returns The password reset form UI ready to be mounted in the app layout.
*/
export function ResetPassword({ className }: ResetPasswordProps) {
const {
authClient,
basePaths,
emailAndPassword,
localization,
navigate,
redirectTo,
viewPaths,
Link
} = useAuth()
const signInURL = getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)
const { mutateAsync: resetPassword, isPending } = useResetPassword(
authClient,
{
onError: (error) => {
// The haveIBeenPwned plugin rejects on the password itself, so it
// belongs against the field rather than in a toast.
if (isPasswordCompromisedError(error)) {
setIsCompromised(true)
}
},
onSuccess: () => {
toast.success(localization.auth.passwordResetSuccess)
navigate({ to: signInURL })
}
}
)
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
useState(false)
const [isCompromised, setIsCompromised] = useState(false)
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)
const token = searchParams.get("token") as string
if (!token) {
toast.error(localization.auth.invalidResetPasswordToken)
navigate({ to: signInURL })
}
}, [localization.auth.invalidResetPasswordToken, navigate, signInURL])
const form = useAuthForm({
defaultValues: { confirmPassword: "", password: "" },
onSubmit: async ({ value }) => {
const searchParams = new URLSearchParams(window.location.search)
const token = searchParams.get("token") as string
if (!token) {
toast.error(localization.auth.invalidResetPasswordToken)
navigate({ to: signInURL })
return
}
try {
await resetPassword({ token, newPassword: value.password })
} catch {
// The mutation reports the error through its configured handler.
}
}
})
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.resetPassword}
</CardTitle>
</CardHeader>
<CardContent>
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
<form.AppField
name="password"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
maxLength: emailAndPassword?.maxPasswordLength,
maxLengthMessage: localization.auth.tooLong.replace(
"{{max}}",
String(emailAndPassword?.maxPasswordLength)
),
minLength: emailAndPassword?.minPasswordLength,
minLengthMessage: localization.auth.tooShort.replace(
"{{min}}",
String(emailAndPassword?.minPasswordLength)
),
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid =
isAuthFormFieldInvalid(field.state.meta) || isCompromised
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="password">
{localization.auth.password}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="password"
type={isPasswordVisible ? "text" : "password"}
autoComplete="new-password"
placeholder={localization.auth.newPasswordPlaceholder}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
name={field.name}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value)
setIsCompromised(false)
}}
aria-invalid={isInvalid}
value={field.state.value}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
title={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() => {
setIsPasswordVisible((visible) => !visible)
}}
>
{isPasswordVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{isCompromised ? (
<FieldError>
{localization.auth.passwordCompromised}
</FieldError>
) : (
<field.AuthFormFieldError />
)}
<PasswordStrengthMeter password={field.state.value} />
</Field>
)
}}
</form.AppField>
{emailAndPassword?.confirmPassword && (
<form.AppField
name="confirmPassword"
validators={{
onChangeListenTo: ["password"],
onChange: ({ fieldApi, value }) =>
validateStringLength(value, {
maxLength: emailAndPassword?.maxPasswordLength,
maxLengthMessage: localization.auth.tooLong.replace(
"{{max}}",
String(emailAndPassword?.maxPasswordLength)
),
minLength: emailAndPassword?.minPasswordLength,
minLengthMessage: localization.auth.tooShort.replace(
"{{min}}",
String(emailAndPassword?.minPasswordLength)
),
requiredMessage: localization.auth.fieldRequired
}) ??
validateMatchingValue(
value,
fieldApi.form.getFieldValue("password"),
localization.auth.passwordsDoNotMatch
)
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="confirmPassword">
{localization.auth.confirmPassword}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="confirmPassword"
name={field.name}
type={
isConfirmPasswordVisible ? "text" : "password"
}
autoComplete="new-password"
placeholder={
localization.auth.confirmPasswordPlaceholder
}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
value={field.state.value}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isConfirmPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
title={
isConfirmPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() => {
setIsConfirmPasswordVisible(
(visible) => !visible
)
}}
>
{isConfirmPasswordVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
)}
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton disabled={isPending}>
{localization.auth.resetPassword}
</form.AuthFormSubmitButton>
</div>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.rememberYourPassword}{" "}
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/sign-in.tsx
"use client"
import {
authMutationKeys,
validateEmailAddress,
validateStringLength
} from "@better-auth-ui/core"
import {
isPasskeyAutoFillEnabled,
withPasskeyAutoFill
} from "@better-auth-ui/core/plugins/passkey"
import {
AuthPrompts,
useAuth,
useFetchOptions,
useSignInEmail
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import { Eye, EyeOff } from "lucide-react"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { useSignInContinuation } from "@/lib/auth/use-sign-in-continuation"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "./auth-form"
import { LastUsedBadge } from "./last-login-method/last-used-badge"
import { ProviderButtons, type SocialLayout } from "./provider-buttons"
import { ReauthenticationNotice } from "./reauthentication"
export type SignInProps = {
className?: string
socialLayout?: SocialLayout
socialPosition?: "top" | "bottom"
}
/**
* Render the sign-in form UI with email/password, magic link, and social provider options.
*
* @param className - Optional additional container class names
* @param socialLayout - Layout style for social provider buttons
* @param socialPosition - Position of social provider buttons; `"top"` or `"bottom"`. Defaults to `"bottom"`.
* @returns The rendered sign-in UI as a JSX element
*/
export function SignIn({
className,
socialLayout,
socialPosition = "bottom"
}: SignInProps) {
const {
authClient,
basePaths,
emailAndPassword,
localization,
plugins,
socialProviders,
viewPaths,
navigate,
Link
} = useAuth()
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const continueSignIn = useSignInContinuation()
const { mutateAsync: signInEmail, isPending: signInEmailPending } =
useSignInEmail(authClient, {
onError: (error, { email }) => {
form.setFieldValue("password", "")
if (error.error?.code === "EMAIL_NOT_VERIFIED") {
sessionStorage.setItem("better-auth-ui.verify-email", email)
navigate({
to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
})
}
resetFetchOptions()
},
onSuccess: (data) => continueSignIn(data)
})
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const signUpMutating = useIsMutating({
mutationKey: authMutationKeys.signUp.all
})
const isPending = signInMutating + signUpMutating > 0
const Captcha = plugins.find(
(plugin) => plugin.captchaComponent
)?.captchaComponent
const passkeyAutoFill = isPasskeyAutoFillEnabled(plugins)
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const form = useAuthForm({
defaultValues: { email: "", password: "", rememberMe: false },
onSubmit: async ({ value }) =>
await signInEmail({
email: value.email,
password: value.password,
...(emailAndPassword?.rememberMe
? { rememberMe: value.rememberMe }
: {}),
fetchOptions
})
})
const showSeparator =
emailAndPassword?.enabled && socialProviders && socialProviders.length > 0
return (
<Card className={cn("w-full max-w-sm", className)}>
<AuthPrompts view="signIn" />
<ReauthenticationNotice />
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.signIn}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
{socialPosition === "top" && (
<>
{socialProviders && socialProviders.length > 0 && (
<ProviderButtons socialLayout={socialLayout} view="signIn" />
)}
{showSeparator && (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card m-0 text-xs flex items-center">
{localization.auth.or}
</FieldSeparator>
)}
</>
)}
{emailAndPassword?.enabled && (
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
<form.AppField
name="email"
validators={{
onChange: ({ value }) =>
validateEmailAddress(value, {
invalidMessage: localization.auth.invalidEmail,
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="email">
{localization.auth.email}
</FieldLabel>
<Input
id="email"
name={field.name}
type="email"
autoComplete={withPasskeyAutoFill(
"email",
passkeyAutoFill
)}
placeholder={localization.auth.emailPlaceholder}
required
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
<form.AppField
name="password"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
maxLength: emailAndPassword?.maxPasswordLength,
maxLengthMessage: localization.auth.tooLong.replace(
"{{max}}",
String(emailAndPassword?.maxPasswordLength)
),
minLength: emailAndPassword?.minPasswordLength,
minLengthMessage: localization.auth.tooShort.replace(
"{{min}}",
String(emailAndPassword?.minPasswordLength)
),
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="password">
{localization.auth.password}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="password"
name={field.name}
type={isPasswordVisible ? "text" : "password"}
autoComplete={withPasskeyAutoFill(
"current-password",
passkeyAutoFill
)}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder={
localization.auth.passwordPlaceholder
}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
aria-invalid={isInvalid}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
title={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() => {
setIsPasswordVisible((visible) => !visible)
}}
>
{isPasswordVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{emailAndPassword.rememberMe && (
<form.AppField name="rememberMe">
{(field) => (
<Field className="my-1">
<div className="flex items-center gap-3">
<Checkbox
id="rememberMe"
name={field.name}
checked={field.state.value}
disabled={isPending}
onCheckedChange={(checked) =>
field.handleChange(checked === true)
}
/>
<FieldLabel
htmlFor="rememberMe"
className="cursor-pointer text-sm font-normal"
>
{localization.auth.rememberMe}
</FieldLabel>
</div>
</Field>
)}
</form.AppField>
)}
{Captcha && (
<div className="flex justify-center">{Captcha}</div>
)}
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton
isPending={signInEmailPending}
className="relative overflow-visible"
disabled={isPending}
>
{localization.auth.signIn}
<LastUsedBadge method="email" floating />
</form.AuthFormSubmitButton>
{plugins.flatMap((plugin) =>
(plugin.authButtons ?? []).map((AuthButton, index) => (
<AuthButton
key={`${plugin.id}-${index.toString()}`}
view="signIn"
/>
))
)}
</div>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
)}
{socialPosition === "bottom" && (
<>
{showSeparator && (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
{localization.auth.or}
</FieldSeparator>
)}
{socialProviders && socialProviders.length > 0 && (
<ProviderButtons socialLayout={socialLayout} view="signIn" />
)}
</>
)}
</div>
<div className="flex flex-col gap-3 items-center w-full mt-4">
{emailAndPassword?.enabled && emailAndPassword?.forgotPassword && (
<Link
href={`${basePaths.auth}/${viewPaths.auth.forgotPassword}`}
className="self-center text-sm underline-offset-4 hover:underline"
>
{localization.auth.forgotPasswordLink}
</Link>
)}
{emailAndPassword?.enabled && (
<FieldDescription className="text-center">
{localization.auth.needToCreateAnAccount}{" "}
<Link
href={`${basePaths.auth}/${viewPaths.auth.signUp}`}
className="underline underline-offset-4"
>
{localization.auth.signUp}
</Link>
</FieldDescription>
)}
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/sign-out.tsx
"use client"
import { useAuth, useSignOut } from "@better-auth-ui/react"
import { useEffect, useRef } from "react"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
export type SignOutProps = {
className?: string
}
/**
* Signs the current user out on mount and renders a centered spinner while the operation completes.
*
* @param className - Optional additional class names appended to the root element
* @returns The spinner shown during sign-out
*/
export function SignOut({ className }: SignOutProps) {
const { authClient, basePaths, navigate, viewPaths } = useAuth()
const { mutate: signOut } = useSignOut(authClient, {
onError: () => {
navigate({
to: `${basePaths.auth}/${viewPaths.auth.signIn}`,
replace: true
})
},
onSuccess: () =>
navigate({
to: `${basePaths.auth}/${viewPaths.auth.signIn}`,
replace: true
})
})
const hasSignedOut = useRef(false)
useEffect(() => {
if (hasSignedOut.current) return
hasSignedOut.current = true
signOut()
}, [signOut])
return <Spinner className={cn("mx-auto my-auto", className)} />
}
examples/start-shadcn-example/src/components/auth/sign-up.tsx
"use client"
import {
authMutationKeys,
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
getAuthCallbackURL,
getAuthLinkURL,
isPasswordCompromisedError,
validateEmailAddress,
validateMatchingValue,
validateStringLength
} from "@better-auth-ui/core"
import {
AuthPrompts,
useAuth,
useFetchOptions,
useSignUpEmail
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import { Eye, EyeOff } from "lucide-react"
import { useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSeparator
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { cn } from "cn"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "./auth-form"
import { PasswordStrengthMeter } from "./password-strength-meter"
import { ProviderButtons, type SocialLayout } from "./provider-buttons"
export type SignUpProps = {
className?: string
socialLayout?: SocialLayout
socialPosition?: "top" | "bottom"
/**
* Runs instead of the post-sign-up redirect, but only when the sign-up
* created an immediately usable session. Email verification still takes
* priority, and social sign-ups are unaffected.
*/
onSignUpSuccess?: () => void
}
/**
* Renders a sign-up form with name, email, and password fields, optional social provider buttons, and submission handling.
*
* Submits credentials to the configured auth client and handles the response:
* - If email verification is required, shows a notification and navigates to sign-in
* - On success, refreshes the session and navigates to the configured redirect path
* - On failure, displays error toasts
* - Manages a pending state while the request is in-flight
*
* @param className - Additional CSS classes applied to the outer container
* @param socialLayout - Social layout to apply to the component
* @param socialPosition - Social position to apply to the component
* @param onSignUpSuccess - Replaces the post-sign-up redirect when the new account is immediately usable
* @returns The sign-up form React element.
*/
export function SignUp({
className,
socialLayout,
socialPosition = "bottom",
onSignUpSuccess
}: SignUpProps) {
const {
additionalFields,
authClient,
basePaths,
baseURL,
emailAndPassword,
localization,
plugins,
redirectTo,
socialProviders,
viewPaths,
navigate,
Link
} = useAuth()
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const { mutateAsync: signUpEmail } = useSignUpEmail(authClient, {
onError: (error) => {
// The haveIBeenPwned plugin rejects on the password itself,
// so it belongs against the field rather than in a toast.
if (isPasswordCompromisedError(error)) {
setIsCompromised(true)
}
form.setFieldValue("password", "")
form.setFieldValue("confirmPassword", "")
resetFetchOptions()
},
onSuccess: (_data, { email }) => {
if (emailAndPassword?.requireEmailVerification) {
sessionStorage.setItem("better-auth-ui.verify-email", email)
navigate({
to: getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.verifyEmail}`,
redirectTo
)
})
} else if (onSignUpSuccess) {
onSignUpSuccess()
} else {
navigate({ to: redirectTo })
}
}
})
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const signUpMutating = useIsMutating({
mutationKey: authMutationKeys.signUp.all
})
const isPending = signInMutating + signUpMutating > 0
const Captcha = plugins.find(
(plugin) => plugin.captchaComponent
)?.captchaComponent
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
useState(false)
const [isCompromised, setIsCompromised] = useState(false)
const signUpFields = useMemo(
() => additionalFields?.filter((field) => field.signUp) ?? [],
[additionalFields]
)
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(signUpFields),
confirmPassword: "",
email: "",
name: "",
password: ""
},
onSubmit: async ({ value }) => {
try {
await signUpEmail({
name: emailAndPassword?.name === false ? "" : value.name,
email: value.email.trim(),
password: value.password,
...getAdditionalFieldSubmitValues(
signUpFields,
value.additionalFields
),
callbackURL: getAuthCallbackURL(baseURL, redirectTo),
fetchOptions
})
} catch {
// The mutation reports the error through its configured handler.
}
}
})
const showSeparator =
emailAndPassword?.enabled && socialProviders && socialProviders.length > 0
return (
<Card className={cn("w-full max-w-sm", className)}>
<AuthPrompts view="signUp" />
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.signUp}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
{socialPosition === "top" && (
<>
{socialProviders && socialProviders.length > 0 && (
<ProviderButtons socialLayout={socialLayout} view="signUp" />
)}
{showSeparator && (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
{localization.auth.or}
</FieldSeparator>
)}
</>
)}
{emailAndPassword?.enabled && (
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
{emailAndPassword.name !== false && (
<form.AppField
name="name"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: localization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(
field.state.meta
)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="name">
{localization.auth.name}
</FieldLabel>
<Input
id="name"
name={field.name}
type="text"
autoComplete="name"
placeholder={localization.auth.namePlaceholder}
required
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
)}
<form.AppField
name="email"
validators={{
onChange: ({ value }) =>
validateEmailAddress(value, {
invalidMessage: localization.auth.invalidEmail,
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="email">
{localization.auth.email}
</FieldLabel>
<Input
id="email"
name={field.name}
type="email"
autoComplete="email"
placeholder={localization.auth.emailPlaceholder}
required
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{signUpFields.map(
(configuredField) =>
configuredField.signUp === "above" && (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={isPending}
optionalLabel={localization.auth.optional}
/>
)}
</form.AppField>
)
)}
<form.AppField
name="password"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
maxLength: emailAndPassword?.maxPasswordLength,
maxLengthMessage: localization.auth.tooLong.replace(
"{{max}}",
String(emailAndPassword?.maxPasswordLength)
),
minLength: emailAndPassword?.minPasswordLength,
minLengthMessage: localization.auth.tooShort.replace(
"{{min}}",
String(emailAndPassword?.minPasswordLength)
),
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid =
isAuthFormFieldInvalid(field.state.meta) ||
isCompromised
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="password">
{localization.auth.password}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="password"
name={field.name}
type={isPasswordVisible ? "text" : "password"}
autoComplete="new-password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value)
setIsCompromised(false)
}}
placeholder={
localization.auth.passwordPlaceholder
}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
aria-invalid={isInvalid}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
title={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() => {
setIsPasswordVisible((visible) => !visible)
}}
>
{isPasswordVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{isCompromised ? (
<FieldError>
{localization.auth.passwordCompromised}
</FieldError>
) : (
<field.AuthFormFieldError />
)}
<PasswordStrengthMeter password={field.state.value} />
</Field>
)
}}
</form.AppField>
{emailAndPassword?.confirmPassword && (
<form.AppField
name="confirmPassword"
validators={{
onChangeListenTo: ["password"],
onChange: ({ fieldApi, value }) =>
validateStringLength(value, {
maxLength: emailAndPassword?.maxPasswordLength,
maxLengthMessage: localization.auth.tooLong.replace(
"{{max}}",
String(emailAndPassword?.maxPasswordLength)
),
minLength: emailAndPassword?.minPasswordLength,
minLengthMessage:
localization.auth.tooShort.replace(
"{{min}}",
String(emailAndPassword?.minPasswordLength)
),
requiredMessage: localization.auth.fieldRequired
}) ??
validateMatchingValue(
value,
fieldApi.form.getFieldValue("password"),
localization.auth.passwordsDoNotMatch
)
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(
field.state.meta
)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="confirmPassword">
{localization.auth.confirmPassword}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="confirmPassword"
name={field.name}
type={
isConfirmPasswordVisible ? "text" : "password"
}
autoComplete="new-password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange(e.target.value)
}
placeholder={
localization.auth.confirmPasswordPlaceholder
}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
aria-invalid={isInvalid}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isConfirmPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
title={
isConfirmPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() =>
setIsConfirmPasswordVisible(
(visible) => !visible
)
}
>
{isConfirmPasswordVisible ? (
<EyeOff />
) : (
<Eye />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
)}
{signUpFields.map(
(configuredField) =>
configuredField.signUp !== "above" && (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={isPending}
optionalLabel={localization.auth.optional}
/>
)}
</form.AppField>
)
)}
{Captcha && (
<div className="flex justify-center">{Captcha}</div>
)}
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton disabled={isPending}>
{localization.auth.signUp}
</form.AuthFormSubmitButton>
{plugins.flatMap((plugin) =>
(plugin.authButtons ?? []).map((AuthButton, index) => (
<AuthButton
key={`${plugin.id}-${index.toString()}`}
view="signUp"
/>
))
)}
</div>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
)}
{socialPosition === "bottom" && (
<>
{showSeparator && (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
{localization.auth.or}
</FieldSeparator>
)}
{socialProviders && socialProviders.length > 0 && (
<ProviderButtons socialLayout={socialLayout} view="signUp" />
)}
</>
)}
</div>
{emailAndPassword?.enabled && (
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.alreadyHaveAnAccount}{" "}
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
)}
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/use-is-hydrated.ts
"use client"
import { useSyncExternalStore } from "react"
/**
* Returns `true` once the component is mounted on the client (hydrated) and
* `false` while rendering on the server, so client-only reads (e.g.
* `sessionStorage`) stay safe during SSR.
*
* @returns Whether the component has hydrated on the client.
*/
export function useIsHydrated() {
const subscribe = () => () => {}
return useSyncExternalStore(
subscribe,
() => true,
() => false
)
}
examples/start-shadcn-example/src/components/auth/verify-email.tsx
"use client"
import { useAuth, useSendVerificationEmail } from "@better-auth-ui/react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
import { OpenEmailButton } from "./open-email-button"
import { useIsHydrated } from "./use-is-hydrated"
export type VerifyEmailProps = {
className?: string
}
/** Seconds the resend button stays disabled to prevent spamming the endpoint. */
const RESEND_COOLDOWN_SECONDS = 60
/**
* Render a card prompting the user to verify their email, with a resend button
* that is rate-limited by a cooldown timer.
*
* The target email is read from `sessionStorage` (set when sign-up or sign-in
* redirects here); the OpenEmail/Resend controls are only shown when an email
* is stored. The resend button is disabled while a cooldown is active and shows
* the remaining seconds.
*
* @param className - Additional CSS classes applied to the card
* @returns The verify-email card React element
*/
export function VerifyEmail({ className }: VerifyEmailProps) {
const {
authClient,
basePaths,
baseURL,
localization,
redirectTo,
viewPaths,
Link
} = useAuth()
const isHydrated = useIsHydrated()
const [email, setEmail] = useState(
(isHydrated && sessionStorage.getItem("better-auth-ui.verify-email")) || ""
)
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS)
useEffect(() => {
setEmail(sessionStorage.getItem("better-auth-ui.verify-email") ?? "")
}, [])
useEffect(() => {
if (cooldown <= 0 || !email) return
const interval = setInterval(() => {
setCooldown((current) => (current > 0 ? current - 1 : 0))
}, 1000)
return () => clearInterval(interval)
}, [cooldown, email])
const { mutate: sendVerificationEmail, isPending } = useSendVerificationEmail(
authClient,
{
onSuccess: () => {
toast.success(localization.auth.verificationEmailSent)
setCooldown(RESEND_COOLDOWN_SECONDS)
}
}
)
const isCoolingDown = cooldown > 0
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.verifyEmail}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<FieldDescription>
{localization.auth.checkYourEmail}
</FieldDescription>
{email && (
<div className="flex flex-col gap-3">
<OpenEmailButton email={email} />
<Button
type="button"
variant="outline"
disabled={!email || isCoolingDown || isPending}
onClick={() =>
sendVerificationEmail({
email,
callbackURL: `${baseURL}${redirectTo}`
})
}
>
{isPending && <Spinner />}
{isCoolingDown
? localization.auth.resendIn.replace(
"{{seconds}}",
String(cooldown)
)
: localization.auth.resend}
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.alreadyVerifiedYourEmail}{" "}
<Link
href={`${basePaths.auth}/${viewPaths.auth.signIn}`}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/lib/auth/two-factor-methods.ts
/**
* Second-factor methods a user can complete the challenge with.
*
* `totp` and `otp` come from the server's `twoFactorMethods` list. Backup
* codes are always available when the server has them enabled, so they are
* never part of that list.
*/
export type TwoFactorMethod = "totp" | "otp"
const TWO_FACTOR_METHODS: TwoFactorMethod[] = ["totp", "otp"]
/** Auth plugin id used by Better Auth UI's two-factor integration. */
export const TWO_FACTOR_PLUGIN_ID = "twoFactor"
/**
* `sessionStorage` key holding the methods reported by the sign-in response.
*
* Only the non-sensitive method names are stored, never a code, token, or the
* two-factor cookie, which stays HTTP-only.
*/
export const TWO_FACTOR_METHODS_STORAGE_KEY =
"better-auth-ui.two-factor-methods"
type TwoFactorRedirect = {
twoFactorRedirect: true
twoFactorMethods?: unknown
}
/** Detect the redirect payload Better Auth returns before a second factor. */
export function isTwoFactorRedirect(data: unknown): data is TwoFactorRedirect {
return (
typeof data === "object" &&
data !== null &&
(data as { twoFactorRedirect?: unknown }).twoFactorRedirect === true
)
}
/** Narrow arbitrary method names to the challenge views this UI supports. */
export function parseTwoFactorMethods(methods?: unknown): TwoFactorMethod[] {
if (!Array.isArray(methods)) return []
return TWO_FACTOR_METHODS.filter((method) => methods.includes(method))
}
/** Persist the enabled method names without blocking sign-in on storage errors. */
export function storeTwoFactorMethods(methods?: unknown) {
if (typeof sessionStorage === "undefined") return
try {
sessionStorage.setItem(
TWO_FACTOR_METHODS_STORAGE_KEY,
JSON.stringify(parseTwoFactorMethods(methods))
)
} catch {
// The challenge falls back to every method when storage is unavailable.
}
}
/** Read the stored methods, falling back to every supported challenge. */
export function readTwoFactorMethods(): TwoFactorMethod[] {
if (typeof sessionStorage === "undefined") return TWO_FACTOR_METHODS
try {
const stored = sessionStorage.getItem(TWO_FACTOR_METHODS_STORAGE_KEY)
if (!stored) return TWO_FACTOR_METHODS
const methods = parseTwoFactorMethods(JSON.parse(stored))
return methods.length ? methods : TWO_FACTOR_METHODS
} catch {
return TWO_FACTOR_METHODS
}
}
/** Clear stored method hints after the challenge finishes or is abandoned. */
export function clearTwoFactorMethods() {
if (typeof sessionStorage === "undefined") return
try {
sessionStorage.removeItem(TWO_FACTOR_METHODS_STORAGE_KEY)
} catch {
// Stale method hints are harmless and must not block navigation.
}
}
examples/start-shadcn-example/src/lib/auth/use-sign-in-continuation.ts
"use client"
import { useAuth } from "@better-auth-ui/react"
import { useCallback } from "react"
import {
isTwoFactorRedirect,
storeTwoFactorMethods,
TWO_FACTOR_PLUGIN_ID
} from "./two-factor-methods"
/**
* Resolve what happens after a sign-in request succeeds.
*
* Better Auth withholds the session when a second factor is required and
* answers with `{ twoFactorRedirect: true, twoFactorMethods }` instead, so no
* sign-in strategy may navigate to `redirectTo` unconditionally. This hook is
* the single place that decision lives — every password-based form calls it
* from `onSuccess`.
*
* The enabled methods are stashed in session storage (names only, never a
* code or token) and `redirectTo` rides along in the query string so the
* challenge view can finish the original navigation.
*
* The two-factor plugin is looked up by its stable id, so sign-in forms stay
* installable without either the two-factor components or a matching release
* of `@better-auth-ui/core`.
*
* @returns A callback taking the resolved data of a sign-in mutation.
*/
export function useSignInContinuation() {
const { basePaths, navigate, plugins, redirectTo } = useAuth()
const twoFactorPath = plugins.find(
(plugin) => plugin.id === TWO_FACTOR_PLUGIN_ID
)?.viewPaths?.auth?.twoFactor
return useCallback(
(data: unknown) => {
if (twoFactorPath && isTwoFactorRedirect(data)) {
storeTwoFactorMethods(data.twoFactorMethods)
navigate({
to: `${basePaths.auth}/${twoFactorPath}?redirectTo=${encodeURIComponent(redirectTo)}`
})
return
}
navigate({ to: redirectTo })
},
[basePaths.auth, navigate, redirectTo, twoFactorPath]
)
}