Skip to content

Monorepo Architecture

Shared Types That Also Check The Data.

One contract at the boundary. Types follow from it.

Aidxn
Written by Aiden Wood Founder and Lead Design Engineer
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. SOURCEdata in DECIDElogic RESULTaction out

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.

Preview Quick Lead Form

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.

Sources and further reading

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.