Auth UI

Copy-in sign-in, settings, and organization screens for React, Vue, Svelte, Solid, and Angular — the code lands in your project and you own it.

Last updated:

@lunora/auth gives you the server half of authentication. Auth UI gives you the screens: sign in/up, forgot/reset password, magic link, email OTP, two-factor (verify and setup), account & security settings, and organizations — for all five base frameworks.

They are not a dependency. One command copies the code into your project, and from that moment it is your code:

lunora add auth-ui

The framework is detected from your package.json (@lunora/react → React, @lunora/vue → Vue, and so on, falling back to a plain react/next dependency), and the base auth server item is pulled in automatically if you haven't added it yet.

What lands in your project

lunora/auth-ui/
  core/         framework-agnostic flow controllers (the logic)
  react/        the views: cards, <AuthUIProvider>, useController
  client.ts     your better-auth client — the seam you edit
  styles.css    minimal CSS on the Lunora design tokens (no Tailwind)

core/ is plain TypeScript — a small external store per flow, holding the fields, validation, pending state, and error mapping. The framework folder is a thin binding over it. You will spend your time in the views; the controllers are there when you want to build a screen the cards don't cover.

Wire it up

Import the stylesheet once, then wrap your auth routes in the provider. Pass your router in through nav (and optionally Link) so navigation stays client-side — one component set serves Next, react-router, TanStack Start, and Astro islands:

import { useNavigate } from "react-router";

import { authClient } from "./lunora/auth-ui/client";
import { AuthUIProvider, SignInCard } from "./lunora/auth-ui/react";
import "./lunora/auth-ui/styles.css";

function SignInPage() {
    const navigate = useNavigate();

    return (
        <AuthUIProvider
            authClient={authClient}
            nav={{ navigate: (to) => navigate(to), replace: (to) => navigate(to, { replace: true }) }}
            redirects={{ afterSignIn: "/app", afterSignOut: "/" }}
            social={["github"]}
        >
            <SignInCard />
        </AuthUIProvider>
    );
}

nav is optional and falls back to a location-based adapter, which is fine for a plain SPA and wrong for anything with a router — a full page load on every sign-in. Pass it.

Social buttons render only for the providers you list in social, and only work once the matching provider is configured server-side.

Provider options

PropWhat it does
authClientYour better-auth client, from lunora/auth-ui/client.ts. Required.
nav / LinkRouter bridge for redirects and internal links.
redirectsafterSignIn, afterSignOut, and the route hosting your sign-in screen.
socialOAuth providers to render buttons for, e.g. ["github", "google"].
basePathWhere better-auth is mounted. Defaults to /api/auth.
localizationPartial overrides for every string the cards render.
onSessionChangeFires after any successful auth mutation — refresh your app's session state here.
onErrorObserves failures; the card still shows the error itself.
pluginsForce an optional flow on or off; detected from your client by default.
themeRetint the cards from config — see Making them yours.

onSessionChange matters more than it looks. A same-origin cookie sign-in changes no token, so useAuth has nothing to react to — wire this to whatever re-resolves your identity and the rest of the app follows the sign-in.

The cards

Auth flows, from lunora/auth-ui/react:

SignInCard, SignUpCard, ForgotPasswordCard, ResetPasswordCard, MagicLinkCard, EmailOtpCard, TwoFactorCard.

Account & security, for a signed-in user:

ProfileCard, ChangeEmailCard, ChangePasswordCard, SessionsCard (list and revoke other sessions), PasskeysCard (list, add, remove), TwoFactorSetupCard (password → TOTP URI + backup codes → verify → enabled), DeleteAccountCard, SignOutButton.

UserButton is the avatar menu (with UserAvatar and UserView if you want the pieces), AvatarCard adds photo upload, LinkedAccountsCard links and unlinks OAuth providers, SetUsernameCard claims a username, and AppearanceCard is light / dark / system.

Organizations:

OrganizationsCard (list, create, switch, leave, delete), MembersCard (members and pending invitations: invite, change role, remove, cancel), OrganizationSettingsCard (rename the active org, edit its slug and logo), OrganizationLogoCard (logo upload), TeamsCard, UserInvitationsCard (the invitations waiting for you) and AcceptInvitationCard (the screen an invitation link lands on).

Plugin-backed, each rendering only when its plugin is enabled:

MultiSessionCard (switch between accounts signed in on this device), AdminUsersCard (search, ban, change role, impersonate), DeviceAuthorizationCard (approve a TV or CLI by code), BackupCodesCard, UsernameSignInCard, PhoneSignInCard, AnonymousButton, OneTap, and Captcha.

Email verification: VerifyEmailCard (the page the link lands on) and ResendVerificationCard.

ErrorToaster is optional and mounts once in your app shell. It shows only the errors that have no card to land in — a social sign-in that failed to redirect, a failed unlink — because everything else already renders on its own card's banner.

Each is independent — mount the ones you want, on the routes you want. There is no router, layout, or shell imposed on you.

One route instead of ten

AuthView maps a URL segment to the right card, so you can host every auth screen on a single route:

// e.g. /auth/:view — sign-in, sign-up, forgot-password, magic-link, …
<AuthView view={params.view} />

The segments are yours to rename through the provider's viewPaths. An unrecognized segment falls back to the sign-in card rather than rendering nothing.

TwoFactorSetupCard renders the otpauth:// URI and the backup codes as text rather than pulling in a QR library. If you want a QR code, render one from the URI with whatever library you already ship.

Enabling the optional flows

Magic link, email OTP, two-factor, passkeys, and organizations each need two halves. The server half is a registry item or a better-auth plugin; the client half is a toggle in lunora/auth-ui/client.ts:

import { createLunoraAuthClient } from "@lunora/auth/plugins/client";
import { createAuthClient } from "better-auth/react";

export const authClient = createLunoraAuthClient(createAuthClient, {
    plugins: {
        emailOtp: true,
        magicLink: true,
        organization: true,
        passkey: true,
        twoFactor: true,
    },
});

createLunoraAuthClient assembles the client plugin set from those toggles so you don't hand-list deep imports, and defaults baseURL to the current origin. You pass createAuthClient in because the variant has to match your UI framework. (Prefer to own that call yourself? lunoraAuthPlugins returns just the plugin array.) For the server side, run lunora add auth-magic-link or lunora add auth-otp, and see @lunora/auth/plugins for the rest.

The server can just tell it

The list above declares your client plugins. It does not have to be the only place a flow is declared, because the server already knows what it runs — add uiConfig() to your auth instance and the screens configure themselves:

import { uiConfig } from "@lunora/auth/plugins";

export const auth = createAuth({
    // …
    plugins: [uiConfig()],
});

lunora add auth wires this in for you. It serves a public GET /api/auth/ui-config describing which plugins are enabled, which social providers are configured, and whether password sign-in and sign-up are open — only facts a sign-in page reveals by existing. Your session policy, rate limits and user-field schemas are not in it.

The practical effect: add a social provider server-side and its button appears, with no second list to keep in sync. social on the provider becomes an override for pinning or reordering, not a requirement.

The two answers are combined rather than ranked. A card renders when the server has the endpoint and your client registered the plugin that drives it — they answer different halves, and a flow with only one half is broken in a way a rendered card would hide (passkey without passkeyClient() has a live endpoint and no WebAuthn ceremony to reach it). A card whose flow is off renders nothing rather than failing at call time, and says why in the console during development.

Without uiConfig() mounted, discovery degrades silently and your client's list is the only source — exactly the previous behaviour. Override either way with the provider's plugins prop, or turn discovery off with discover={false}.

Password rules

The rule that matters is your server's — better-auth rejects what it rejects. A UI with its own hard-coded minimum is guessing: set it lower and the user is told "too short" only after a round-trip; set it higher and you refuse passwords the server would have taken. So the policy is config, and you match it to emailAndPassword:

<AuthUIProvider
    authClient={authClient}
    password={{ minLength: 12, requireDigit: true, requireUppercase: true }}
>

Every password field validates against it, and <PasswordStrength> renders the live checklist under the sign-up field. It is a checklist rather than a bare "weak / strong" bar on purpose — a bar tells someone their password is unacceptable without telling them what to change. The bar that is there is derived from the same requirements, so the two can never disagree.

Unset, the policy is better-auth's own default: 8–128 characters and no composition rules.

Username availability

With the username plugin on, <SignUpCard> and <SetUsernameCard> check availability as the user types, so a taken name surfaces before the submit rather than as a failed sign-up with the whole form to re-check.

It is debounced, and a slow answer for an earlier value is discarded rather than applied — that out-of-order race is how these end up confidently reporting the wrong thing. The result is advisory: the name can still be taken between the check and the submit, so the server stays the authority and a failed check never blocks a submit on its own.

Being an OAuth provider

Everywhere else your app is the OAuth client — the user signs in to you. With @better-auth/oauth-provider wired server-side, it can be the authorization server too, and two screens are yours to host:

ConsentCard is what a third-party application redirects the user into. It names the application, lists exactly the scopes being requested, and offers Allow and Deny. AuthorizedAppsCard is where that consent can be taken back.

// e.g. /oauth/consent — the id arrives as ?consent_id=
<ConsentCard />

Both are deliberately plain, and two properties are enforced in the controller rather than left to the view: nothing is ever pre-approved (an auto-accepting consent screen is indistinguishable from no consent screen), and denying is exactly as cheap as allowing. If the request can't be loaded, the screen resolves to no decision rather than to a default — a broken consent screen must not become an approving one.

Turn it on with oauthProvider: true in lunora/auth-ui/client.ts alongside the server plugin.

Avatar and logo uploads

user.image and organization.logo are plain string columns in better-auth, so this package has no opinion about where the bytes live. Give the provider an upload handler and AvatarCard / OrganizationLogoCard switch from a URL field to a file picker:

<AuthUIProvider
    authClient={authClient}
    avatar={{ maxSize: 2 * 1024 * 1024, upload: async (file) => uploadToR2(file) }}
>

Return the URL to store. Without a handler neither card renders, and the URL fields on ProfileCard / OrganizationSettingsCard remain the way to set one.

Email templates

lunora add auth-emails copies styled templates for the mail these flows send — verify address, reset password, magic link, one-time code, organization invitation, and the security notices after a change — into lunora/auth/emails.tsx. They are rendered by your Worker through @lunora/mail, so they are the same mail whichever frontend framework you use:

import { renderEmail } from "@lunora/mail";
import { ResetPasswordEmail } from "./emails";

const { html, text } = await renderEmail(<ResetPasswordEmail url={url} />);

They are a separate item rather than part of the screens because they pull in react + @react-email/render server-side, which an app happy with the base auth item's plain-text bodies has no reason to take on.

The other frameworks

Same controllers, same class names, same stylesheet; only the binding differs.

// Vue — a plugin, plus <AuthUIProvider> if you'd rather scope it
import { createAuthUI } from "./lunora/auth-ui/vue";

createApp(App).use(createAuthUI({ authClient })).mount("#app");
<!-- Svelte 5 — components from lunora/auth-ui/svelte -->
<script lang="ts">
    import { authClient } from "./lunora/auth-ui/client";
    import { AuthUIProvider, SignInCard } from "./lunora/auth-ui/svelte";
</script>

<AuthUIProvider {authClient}>
    <SignInCard />
</AuthUIProvider>

Solid mirrors the React API exactly (AuthUIProvider, the same card names, with createController in place of useController). Angular ships standalone signal components — provideAuthUI({ authClient }) in app.config.ts, then SignInCardComponent and friends with lunora-* selectors:

import { provideAuthUI } from "./lunora/auth-ui/angular";

export const appConfig: ApplicationConfig = {
    providers: [provideAuthUI({ authClient })],
};

Meta-frameworks

The cards are client components in every framework — they hold form state and call better-auth from event handlers. What differs per meta-framework is the router you hand to nav, and where the client boundary sits.

FrameworkItemnav comes from
Next (App Router)auth-ui-reactuseRouter() from next/navigation; Link from next/link
React Router (framework mode)auth-ui-reactuseNavigate() from react-router
TanStack Start (React)auth-ui-reactuseRouter() from @tanstack/react-router
TanStack Start (Solid)auth-ui-soliduseRouter() from @tanstack/solid-router
Astroauth-ui-reactnone needed — see below
Nuxtauth-ui-vueuseRouter() from vue-router
SvelteKitauth-ui-sveltegoto from $app/navigation
Analogauth-ui-angularAngular's Router

Three things are worth knowing before you wire one up.

Next.js. The React views already carry "use client", so you can render a card straight from a server component — no wrapper needed.

Pointing at a different auth origin. Leave it alone and the client uses the current origin, which is right whenever the Worker is same-origin with your app — the default in every template. To override, lunora/auth-ui/client.ts reads VITE_AUTH_URL under Vite (Nuxt, SvelteKit, Astro, TanStack Start, Analog) and falls back to NEXT_PUBLIC_AUTH_URL / PUBLIC_AUTH_URL from process.env on bundlers that are not Vite. Set whichever your framework inlines.

Astro. Each client:* directive creates its own island with its own React root, and context does not cross islands. Put <AuthUIProvider> and the cards in one component and hydrate that:

---
import SignIn from "../components/SignIn"; // renders <AuthUIProvider><SignInCard /></AuthUIProvider>
---

<SignIn client:load />

Hydrating AuthUIProvider and a card as two separate islands throws useAuthUI must be used inside <AuthUIProvider />.

SSR. Every card renders its empty state on the server and fills in on the client — the settings and organization cards fetch on mount, so a server-rendered page shows their loading state. That is intended: session-dependent markup should not be in the SSR payload. Nothing reads window or document at module scope, so no card needs a <ClientOnly> / client:only escape hatch.

React Native / Expo is not supported. The screens render DOM elements and a stylesheet; Metro has nothing to mount them into. lunora add auth-ui detects a React Native project and refuses rather than copying them in. The server half (lunora add auth) and the better-auth Expo bridge (@lunora/react-native/auth) both work — build the screens with React Native primitives against that same client.

Making them yours

Restyle styles.css — it reads the same design tokens as the rest of Lunora, so changing your token values moves the auth screens with everything else. Override copy through the provider's localization prop, or stop being precious about it and edit the cards directly. That is what "you own it" means.

To retint just the auth screens without touching CSS, use theme. It receives the defaults and returns what you want changed:

<AuthUIProvider authClient={authClient} theme={(defaults) => ({ ...defaults, primary: "#5b21b6", radius: "1rem" })}>

Only what you actually change is emitted, as custom properties on the cards — so every token you leave alone keeps inheriting from your app.

Re-running lunora add auth-ui three-way merges upstream changes into your copy. Where a file you edited also changed upstream, you get a .new file next to it instead of a clobbered screen.

See also