npm

Guides

Auth screens

The starter ships complete authentication screens built on one small set of components. Every screen is a sectioned card (header, body, footer) that mirrors the app's dialogs, so the whole set stays consistent and easy to extend.

What's included

Themed, client-side screens live under /auth. They drive a small, provider-agnostic auth contract (below), so wiring a real backend is one adapter, not a screen rewrite:

  • /auth/signin: Google / passkey / SSO / magic-link, with 2FA and SSO sub-views
  • /auth/signup: work-email sign-up with a reCAPTCHA-style gate
  • /auth/forgot-password: request a reset link
  • /auth/reset-password: set a new password
  • /auth/verify: 6-digit verification code (OTP)

Two public pages sit alongside them on the same brand shell: /terms and /privacy. They live under app/(legal)/ rather than app/auth/ because the auth screens are noindex and legal pages need to be found: both carry their own title, description and canonical, and both are in the sitemap. SiteFooter links them, so every screen in the app has them, and the signup form carries the consent line that names them. Compose new ones from LegalTitle, LegalSection and LegalList, and add the route to LEGAL_ROUTES in lib/seo.ts.

The auth contract

The library ships auth screens but deliberately not an auth engine. Bundling a provider (NextAuth, Clerk, Better Auth, Supabase, …) would force its SDK and backend on every consumer. Instead, screens depend on @viliha/vui-ui/auth-context: a tiny interface you implement with an adapter. Swapping providers touches only the adapter; the screens never change.

@viliha/vui-ui/auth-context
export interface AuthContract {
  user: AuthUser | null;
  status: "loading" | "authenticated" | "unauthenticated";
  signIn(creds: { email: string; password: string }): Promise<void>;
  signUp?(input: { email: string; password: string; name?: string }): Promise<void>;
  signInSocial?(provider: string): Promise<void>; // omit → hide the buttons
  signOut(): Promise<void>;
}

Wrap the app once with an adapter, then read it anywhere with useAuth():

app/layout.tsx & any screen
import { AuthProvider, useAuth } from "@viliha/vui-ui/auth-context";

// mount once (see app/_components/auth-provider.tsx)
<AuthProvider value={adapter}>{children}</AuthProvider>

// in a screen
const auth = useAuth();
await auth.signIn({ email, password }); // throws on failure → show the error

How do I wire Better Auth?

The starter ships a Better Auth adapter (app/_components/auth-provider.tsx) that maps the Better Auth React client onto the contract. It activates when NEXT_PUBLIC_AUTH_BASE_URL points at your Better Auth server; otherwise it falls back to an in-memory mock so the static demo keeps working with no backend.

lib/auth/client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_AUTH_BASE_URL,
});
app/_components/auth-provider.tsx (the bridge)
const session = authClient.useSession();
const value: AuthContract = {
  user: session.data?.user ?? null,
  status: session.isPending ? "loading" : session.data ? "authenticated" : "unauthenticated",
  async signIn({ email, password }) {
    const { error } = await authClient.signIn.email({ email, password });
    if (error) throw new Error(error.message);
  },
  async signUp({ email, password, name }) {
    const { error } = await authClient.signUp.email({ email, password, name });
    if (error) throw new Error(error.message);
  },
  signInSocial: (provider) => authClient.signIn.social({ provider, callbackURL: "/dashboard" }),
  signOut: () => authClient.signOut(),
};

This app is a static export

output: "export"means the app can't host Better Auth's /api/auth/* handler itself. Run the server on your own backend (or a non-static deployment) and set NEXT_PUBLIC_AUTH_BASE_URL to its origin. Minimal server:
server (Node/Next route, own deployment)
// auth.ts
import { betterAuth } from "better-auth";
export const auth = betterAuth({
  database: /* your adapter */,
  emailAndPassword: { enabled: true },
  socialProviders: { google: { clientId: "…", clientSecret: "…" } },
});

// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/auth";
export const { GET, POST } = toNextJsHandler(auth);

The building blocks

The pieces live in @/app/_components/auth. An AuthCardis a bordered card split into three sections, using the same treatment as the app's dialogs: muted header and footer with a plain body.

  • AuthCard: the bordered container
  • AuthCardHeader: title, optional description and icon (muted background)
  • AuthCardBody: the fields (roomy, consistent spacing)
  • AuthCardFooter: the primary action(s) (muted background)
  • AuthCardAside: secondary nav (e.g. "Create an account"), set off by its own divider
  • FieldGrid: wraps a form's Fields in a two-column grid: labels line up in column 1, inputs in column 2, so every input starts at the same x
  • Field: a labelled field (render inside a FieldGrid); pass required for the * marker and hint for helper text. An error turns the input border red (via the Input's aria-invalid styling) and shows the message in a tooltip on an alert icon, with no layout shift; the full text is announced to screen readers. The error auto-clears the moment the user edits the field (and re-validates on the next submit), so pages just set error on submit and never clear it on change

How do I build an auth screen?

Compose the sections inside a <form>so the footer's submit button drives the whole card:

app/auth/signin/page.tsx
"use client";

import Link from "next/link";
import { Button } from "@viliha/vui-ui/button";
import { Input } from "@viliha/vui-ui/input";
import {
  AuthCard, AuthCardHeader, AuthCardBody, AuthCardFooter, AuthCardAside,
  FieldGrid, Field,
} from "@/app/_components/auth";

export default function SignIn() {
  return (
    <AuthCard>
      <form onSubmit={handleSubmit}>
        <AuthCardHeader title="Sign in to your account" />
        <AuthCardBody>
          <FieldGrid>
            <Field label="Email" htmlFor="email" required>
              <Input id="email" type="email" placeholder="you@company.com" />
            </Field>
            <Field label="Password" htmlFor="password" required error={error}>
              <Input id="password" type="password" placeholder="Your password" />
            </Field>
          </FieldGrid>
        </AuthCardBody>
        <AuthCardFooter>
          <Button type="submit" className="w-full">Continue</Button>
          <AuthCardAside>
            New here?{" "}
            <Link href="/auth/signup" className="font-medium text-primary hover:underline">
              Create an account
            </Link>
          </AuthCardAside>
        </AuthCardFooter>
      </form>
    </AuthCard>
  );
}

Confirmation states

For "check your email" and other success states, pair a header carrying an icon with a footer of actions, no body needed:

sent state
<AuthCard>
  <AuthCardHeader
    icon={<MailCheck className="size-6" />}
    title="Check your email"
    description={<>A link was sent to <b>{email}</b></>}
  />
  <AuthCardFooter>
    <Button className="w-full" onClick={resend}>Resend link</Button>
  </AuthCardFooter>
</AuthCard>

Layout

Auth screens render inside app/auth/layout.tsx: a brand header (AuthHeader: logo top-left, theme toggle right) and the same footer as the app shell (SiteFooter, full width), with the card centered between them, so moving between auth and the dashboard doesn't feel like a different site. Add a new screen at app/auth/<name>/page.tsx and it inherits the layout. The not-found (404) and error (500) pages reuse the same AuthHeader + SiteFooter shell; the 404 sends signed-in users to the dashboard and everyone else to sign-in (lib/auth-state.ts).

Required fields

<Field required> renders the same * marker (@viliha/vui-ui/required-mark) the datatable uses, so you get one consistent mandatory-field cue across tables, forms, and auth.

How do I validate a field?

Validation runs through one channel only: the field's inline error (red border + alert-triangle tooltip). Use useFormFields from @viliha/vui-ui/use-form-fields. It checks each rule on blur (when you leave the field) and again on submit, clears the error the moment you edit, and works for both Input and Textarea. Set noValidate on the <form> so the browser's native bubble never fires on top of it.

validated sign-in
import { useFormFields } from "@viliha/vui-ui/use-form-fields";

const f = useFormFields({
  email: (v) => (!EMAIL_RE.test(v.trim()) ? "Enter a valid email address." : undefined),
  password: (v) => (v.length < 8 ? "Password must be at least 8 characters." : undefined),
  // cross-field: the rule also gets every value
  confirm: (v, all) => (v !== all.password ? "Passwords don't match." : undefined),
});

<form noValidate onSubmit={(e) => { e.preventDefault(); if (!f.validate()) return; submit(f.values); }}>
  <Field label="Email" htmlFor="email" required error={f.errors.email}>
    <Input id="email" {...f.bind("email")} />        {/* value + onChange + onBlur */}
  </Field>
</form>

On a failed server sign-in, push the message into the same inline channel with f.setError("password", message) — no separate banner.

Password fields

Use PasswordInput (@viliha/vui-ui/password-input) for passwords: it masks with * and adds a show/hide eye toggle. It's a drop-in for Input — spread bind(...) and pass error the same way. The default * mask uses a text input, so password-manager autofill won't recognise it — pass mask="native" for the browser's native password field (bullet dots) when autofill matters more than the asterisk look. To flip the default for every field app-wide, set NEXT_PUBLIC_PASSWORD_MASK=native (see Configuration).