SaaS Starter

Convenciones (do / don't)

Los patrones que mantienen al design system coherente — y los que silenciosamente lo rompen.

Estas reglas vienen de la migración que consolidó tres paletas drifteando en una. Están aplicadas por review, no por lint — leelas antes de mandar un cambio de UI.

Tokens

Do — usá clases semánticas de 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 — hardcodear colores

// Todo esto rompe theme-switching y driftea con el tiempo.
<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)' }}>

Incluso bg-white/[0.05] es un smell salvo que estés modelando un alpha overlay sobre una surface conocida (ej. card hover). El token (hover:bg-accent) se prefiere cuando existe uno.

Fonts

Do — apoyate en el root layout

apps/client/app/layout.tsx ya carga Geist Sans y Geist Mono vía next/font/google y los expone como --font-geist-sans / --font-geist-mono. Usá font-sans y font-mono en componentes.

Don't — re-cargar 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>;
}

La carga de fonts por componente envía <link>s duplicados, anula el preload, y produce inconsistencias FOIT entre routes.

Brand primitives

Do — importá desde @/components/brand

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

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

Don't — re-rollar el wordmark, el grid, el chip

// Don't — tres páginas terminarán con tres wordmarks distintos.
<span className="font-semibold tracking-[-0.02em] text-lg">UseDeploy</span>

// Don't — el grid y el glow son intencionalmente un solo componente por una razón.
<div className="absolute inset-0 [background-image:linear-gradient(...)]" />

Si necesitás una variante que el primitive no expone, agregá una prop al primitive — no lo bifurques.

Páginas de auth

Do — envolvé el contenido del form en <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>
  );
}

El (auth)/layout.tsx monta el chrome de la página (GridGlowBackground, container centrado). La página sólo declara su form.

Don't — re-implementar el 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 es el patrón de bug que produjo tres definiciones de wrapper antes de #145.

Auth client

Do — usá cookies vía apiClient con credentials

const { data, error } = await api.POST('/auth/login', { body: { email, password } });
// Cookie seteada por el server; sin token para guardar.

Don't — alcanzar localStorage / bearer tokens

BetterAuth emite una cookie HTTP-only que el browser manda de vuelta automáticamente porque apiClient tiene credentials: 'include' (withCredentials: true para callsites de axios). No hay token para capturar.

Schemas de form

Do — importá desde @app/contracts, extendé 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 conocidos del server

// Don't — driftea del server, manda 400 a los users, el typecheck no lo agarra.
export const registerFormSchema = z.object({
  fullName: z.string().min(1),
  emailAddress: z.string().email(),
  password: z.string().min(8),
});

Ver contracts para la historia del bug.

Cuando dudás

Abrí el source de un primitive — son chiquitos (todo el directorio brand/ está bajo 400 líneas). Los tokens son 250 líneas de globals.css. Leé el código; es la fuente de verdad.

En esta página