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:
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.
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():
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 errorHow 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.
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_AUTH_BASE_URL,
});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:// 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.
How do I build an auth screen?
Compose the sections inside a <form>so the footer's submit button drives the whole card:
"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:
<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
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.
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
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).