SaaS Starter
Frontend

Conventions (do / don't)

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

Last updated May 7, 2026

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