SaaS Starter

Contracts (@app/contracts)

Por que os schemas Zod do client importam do workspace compartilhado, e o padrão de bug que motivou a regra.

A regra

Quando um form, mutation, ou query toca um endpoint HTTP, o schema Zod do lado client deve vir de @app/contracts. Nunca redefina um campo conhecido do server (email, password, name, …) em apps/client/lib/validations/* ou em qualquer objeto Zod client-only.

Por quê

O client tipado (openapi-fetch + apps/client/lib/api/openapi-types.ts) pega drift de tipos, não drift de payload em runtime.

Já enviamos exatamente este bug: um form de register tinha seu próprio schema com fullName, enquanto o server esperava name. O typecheck passou (o campo era string em ambos os mundos), o form submeteu, o server retornou 400, o usuário viu um erro misterioso.

O pacote contracts é a única fonte de verdade. Ambos os lados importam de lá; ambos os lados quebram em CI quando a forma muda.

Como aplicar

1. Procure primeiro em packages/contracts/

packages/contracts/src/
  auth.ts        Login, register, password reset.
  user.ts        Perfil, list, update.
  common.ts      Paginação, IDs.
  index.ts       Barrel.

Se já existe um schema para o que você precisa, re-exporte. Não bifurque.

2. Estenda, não redefina

Para validação client-only (ex. confirmPassword batendo com password num form de register), importe o schema do contract e .extend(...).refine(...) em cima:

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

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

3. Tire os campos client-only antes do submit

O padrão canônico:

const onSubmit = async (data: RegisterFormValues) => {
  const { confirmPassword: _, ...payload } = data;
  await api.POST('/auth/register', { body: payload });
};

O nome da variável descartada (_) sinaliza a intenção.

BetterAuth usa cookies HTTP-only. apiClient define withCredentials: true para que os cookies trafeguem em toda request. Nunca reintroduza plumbing de bearer-token / localStorage — sessions vivem no Postgres, não no browser.

Quando você muda um schema

Se você muda um campo num schema do contract, a spec OpenAPI muda, o client tipado muda, e o job api-types-fresh do CI falha até você regenerar. Essa é a rede de segurança.

bun run generate:api
git diff apps/server/openapi.json apps/client/lib/api/openapi-types.ts

Commit ambos os arquivos no mesmo change. Veja API contract.

Nesta página