Schema to form, API, and UI
One schema travels through a form and an API boundary. Input is checked at runtime and TypeScript types are inferred from the same definition.
TypeScript can prove what your code says. It cannot prove that a browser, webhook, CSV, or old database row actually sent that shape. That is a runtime boundary problem.
A shared Zod package is useful when it owns a real contract: payloads that multiple apps create, parse, or display. It is not a reason to turn every internal object into a schema. Keep the contract narrow, parse untrusted input, and infer types from the schema rather than hand copying them.
Define the input contract once
Keep the schema close to the data crossing a boundary. A lead form needs a request schema, not a database table masquerading as a public API.
// packages/schemas/src/lead.ts
import * as z from 'zod';
export const CreateLeadSchema = z.object({
name: z.string().trim().min(1).max(120),
email: z.email(),
phone: z.string().trim().max(40).optional(),
business: z.string().trim().max(160).optional(),
message: z.string().trim().max(4_000).optional(),
});
export type CreateLeadInput = z.input<typeof CreateLeadSchema>;
export type CreateLead = z.output<typeof CreateLeadSchema>;
z.input and z.output become especially useful when a schema transforms or coerces data. For a plain object they may be identical. Naming both makes the boundary explicit before it becomes complicated.
Parse at the server boundary
The server remains the authority even if the client checks the form first. safeParse returns a result object, which makes an expected validation failure easy to handle without throwing through the request path.
import { CreateLeadSchema } from '@aidxn/schemas';
export async function POST(request: Request) {
const body: unknown = await request.json();
const result = CreateLeadSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ issues: result.error.issues },
{ status: 400 },
);
}
const lead = result.data;
// Insert lead only after authorisation and server side policy checks.
return Response.json({ ok: true, leadId: await saveLead(lead) }, { status: 201 });
}
Validation is not authorisation. A parsed tenantId is still not permission to access that tenant. Validate shape first, then check the session, tenant, and business rules on the server.
Use the same contract in the form
A component can use the schema for immediate feedback, while the server protects the real boundary. This is a good fit for a reusable lead capture component because the message, errors, and field limits stay aligned across projects.
import { CreateLeadSchema, type CreateLeadInput } from '@aidxn/schemas';
async function submitLead(values: CreateLeadInput) {
const parsed = CreateLeadSchema.safeParse(values);
if (!parsed.success) return { ok: false, issues: parsed.error.issues };
const response = await fetch('/api/leads', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(parsed.data),
});
return response.json();
}
The Quick Lead Form illustrates the product value. It is not merely input markup. It needs a predictable data shape, clear errors, and a server endpoint that never trusts the browser.
Organise by boundary, not by technology
packages/
schemas/
src/
lead.ts
billing.ts
project.ts
index.ts
apps/
web/
admin/
api/
Export contracts from one small package. Avoid importing database ORM types into it unless the database shape really is the external contract. Often it is not: database rows include internal fields, permission fields, and historical values that should never reach the browser.
Velocity Components fit
Reusable components need portable interfaces. Pair a component with its public props and schema contract, then make the demo show valid, empty, loading, and error states. That gives Velocity Components subscribers something better than copy paste markup: a pattern that survives a real app boundary.








