SaaS Starter
Architecture

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.

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.ts

Commit both files in the same change. See API contract.

On this page