SaaS Starter

Convenções (do / don't)

Os padrões que mantêm o design system coerente — e os que silenciosamente quebram.

Estas regras vêm da migração que consolidou três paletas em drift em uma. São aplicadas por review, não por lint — leia antes de mandar uma mudança de UI.

Tokens

Do — use classes semânticas do Tailwind

<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 cores

// Tudo isso quebra theme-switching e drifta com o tempo.
<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)' }}>

Mesmo bg-white/[0.05] é um smell, exceto se você está modelando um alpha overlay sobre uma surface conhecida (ex. card hover). O token (hover:bg-accent) é preferido quando existe um.

Fonts

Do — confie no root layout

apps/client/app/layout.tsx já carrega Geist Sans e Geist Mono via next/font/google e expõe como --font-geist-sans / --font-geist-mono. Use font-sans e font-mono em componentes.

Don't — recarregar fonts por componente

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

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

Carga de fonts por componente envia <link>s duplicados, derrota o preload, e produz inconsistências FOIT entre rotas.

Brand primitives

Do — importe de @/components/brand

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

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

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

// Don't — três páginas vão acabar com três wordmarks diferentes.
<span className="font-semibold tracking-[-0.02em] text-lg">UseDeploy</span>

// Don't — o grid e o glow são intencionalmente um componente por uma razão.
<div className="absolute inset-0 [background-image:linear-gradient(...)]" />

Se você precisa de uma variante que o primitive não expõe, adicione uma prop ao primitive — não bifurque.

Páginas de auth

Do — envolva o conteúdo do form em <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>
  );
}

O (auth)/layout.tsx monta o chrome da página (GridGlowBackground, container centralizado). A página só declara seu form.

Don't — re-implementar o chrome por página

// 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>
);

Este é o padrão de bug que produziu três definições de wrapper antes de #145.

Auth client

Do — use cookies via apiClient com credentials

const { data, error } = await api.POST('/auth/login', { body: { email, password } });
// Cookie definido pelo server; sem token para armazenar.

Don't — alcançar localStorage / bearer tokens

BetterAuth emite um cookie HTTP-only que o browser envia de volta automaticamente porque apiClient tem credentials: 'include' (withCredentials: true para callsites de axios). Não há token para capturar.

Schemas de form

Do — importe de @app/contracts, estenda para campos client-only

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-definir campos conhecidos do server

// Don't — drifta do server, manda 400 aos users, o typecheck não pega.
export const registerFormSchema = z.object({
  fullName: z.string().min(1),
  emailAddress: z.string().email(),
  password: z.string().min(8),
});

Veja contracts para a história do bug.

Em dúvida

Abra o source de um primitive — são pequenos (o diretório brand/ inteiro está abaixo de 400 linhas). Os tokens são 250 linhas de globals.css. Leia o código; é a fonte de verdade.

Nesta página