SaaS Starter
Frontend

Conventions (do / don't)

The patterns that keep the design system coherent — and the ones that quietly break it.

These rules come from the migration that consolidated three drifting palettes into one. They're enforced by review, not by lint — read them before you ship a UI change.

Tokens

Do — use semantic Tailwind classes

<div className="bg-card border border-border text-foreground">
<p className="text-muted-foreground">Helper copy</p>
<button className="bg-primary text-primary-foreground">Continue</button>

Don't — hardcode colors

// All of these break theme-switching and drift over time.
<div className="bg-black border border-white/10 text-white">
<p className="text-zinc-400">Helper copy</p>
<button className="bg-white text-black">Continue</button>
<div style={{ background: 'oklch(0% 0 0)' }}>

Even bg-white/[0.05] is a smell unless you're modelling an alpha overlay over a known surface (e.g. card hover). The token (hover:bg-accent) is preferred when one exists.

Fonts

Do — rely on the root layout

apps/client/app/layout.tsx already loads Geist Sans and Geist Mono via next/font/google and exposes them as --font-geist-sans / --font-geist-mono. Reach for font-sans and font-mono in components.

Don't — re-load fonts per component

// Don't.
import { Geist } from 'next/font/google';
const geist = Geist({ subsets: ['latin'] });

export function MyComponent() {
  return <div className={geist.className}>...</div>;
}

Per-component font loading ships duplicate <link>s, defeats the preload, and produces FOIT inconsistencies between routes.

Brand primitives

Do — import from @/components/brand

import { BrandMark, GridGlowBackground } from '@/components/brand';

<header>
  <BrandMark>
    <VersionChip />
  </BrandMark>
</header>

Don't — re-roll the wordmark, the grid, the chip

// Don't — three pages will end up with three different wordmarks.
<span className="font-semibold tracking-[-0.02em] text-lg">UseDeploy</span>

// Don't — the grid and glow are intentionally one component for a reason.
<div className="absolute inset-0 [background-image:linear-gradient(...)]" />

If you need a variant the primitive doesn't expose, add a prop to the primitive — don't fork it.

Auth pages

Do — wrap form content in <AuthCard>

// app/(auth)/login/page.tsx
export default function LoginPage() {
  return (
    <AuthCard
      title="Sign in"
      subtitle="Welcome back."
      footer={<Link href="/register">Create an account</Link>}
    >
      <LoginForm />
    </AuthCard>
  );
}

The (auth)/layout.tsx mounts the page chrome (GridGlowBackground, centred container). The page only declares its form.

Don't — re-implement the chrome per page

// Don't.
return (
  <div className="min-h-screen bg-black flex items-center justify-center">
    <div className="rounded-lg border border-white/10 bg-white/5 p-8">
      <h1>Sign in</h1>
      <LoginForm />
    </div>
  </div>
);

This is the bug pattern that produced three wrapper definitions before #145.

Auth client

Do — use cookies via apiClient with credentials

const { data, error } = await api.POST('/auth/login', { body: { email, password } });
// Cookie set by the server; no token to store.

Don't — reach for localStorage / bearer tokens

BetterAuth issues an HTTP-only cookie that the browser sends back automatically because apiClient has credentials: 'include' (withCredentials: true for axios callsites). There is no token to capture.

Form schemas

Do — import from @app/contracts, extend for client-only fields

import { auth } from '@app/contracts';

export const registerFormSchema = auth.SignUpInput
  .extend({ confirmPassword: z.string() })
  .refine((d) => d.password === d.confirmPassword, {
    message: 'Passwords must match',
    path: ['confirmPassword'],
  });

Don't — re-define server-known fields

// Don't — drifts from the server, ships a 400 to users, typecheck doesn't catch it.
export const registerFormSchema = z.object({
  fullName: z.string().min(1),
  emailAddress: z.string().email(),
  password: z.string().min(8),
});

See contracts for the bug history.

When in doubt

Open a primitive's source — they're tiny (the entire brand/ directory is under 400 lines). The tokens are 250 lines of globals.css. Read the code; it's the source of truth.

On this page