Contracts (@app/contracts)
Why client Zod schemas import from the shared workspace, and the bug pattern that motivated the rule.
The rule
When a form, mutation, or query touches an HTTP endpoint, the client-side Zod schema must come from @app/contracts. Never re-define a server-known field (email, password, name, …) in apps/client/lib/validations/* or any client-only Zod object.
Why
The typed client (openapi-fetch + apps/client/lib/api/openapi-types.ts) catches type drift, not runtime payload drift.
We shipped this exact bug: a register form had its own schema with fullName, while the server expected name. Typecheck passed (the field was a string in both worlds), the form submitted, the server returned 400, the user saw a mystery error.
The contract package is the single source of truth. Both sides import from it; both sides break in CI when the shape changes.
How to apply
1. Look in packages/contracts/ first
packages/contracts/src/
auth.ts Login, register, password reset.
user.ts Profile, list, update.
common.ts Pagination, IDs.
index.ts Barrel.If a schema for what you need already exists, re-export it. Don't fork.
2. Extend, don't redefine
For client-only validation (e.g. confirmPassword matching password in a register form), import the contract schema and .extend(...).refine(...) on top:
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. Drop client-only fields before submit
The canonical pattern:
const onSubmit = async (data: RegisterFormValues) => {
const { confirmPassword: _, ...payload } = data;
await api.POST('/auth/register', { body: payload });
};The discarded variable name (_) signals intent.
4. Auth client is cookie-based
BetterAuth uses HTTP-only cookies. apiClient sets withCredentials: true so cookies flow on every request. Never reintroduce bearer-token / localStorage plumbing — sessions live in Postgres, not in the browser.
When you change a schema
If you change a field on a contract schema, the OpenAPI spec changes, the typed client changes, and CI's api-types-fresh job fails until you regenerate. That's the safety net.
bun run generate:api
git diff apps/server/openapi.json apps/client/lib/api/openapi-types.tsCommit both files in the same change. See API contract.