One package, three consumers, zero build
packages/shared holds the zod schemas that describe the API, and three things
consume them: the server validates requests and responses with them, the browser parses
responses with them, and the CLI parses the same responses with the same ones. There is exactly
one definition of every shape.
It is consumed as TypeScript source rather than as a compiled artefact. The package's exports
map points @rocketflare/shared at ./src/index.ts and
@rocketflare/shared/* at ./src/*.ts, and every tool in the
workspace — Vite, wrangler, tsx, vitest — resolves the .ts file directly through the
workspace link.
Why no build step. A dist directory introduces a state where the
source and the thing everyone imports disagree, and a class of confusing failures that all
resolve to "rebuild the shared package". Removing the artefact removes the staleness: an edit
is visible to the API, the UI and the CLI on the next type check, with nothing to run in
between.
What lives there
The package is a vocabulary rather than a library. It contains the entity schemas and the request and query schemas for every route, the error envelope, the pagination shape, the permission vocabulary — actions, subjects, the ability type, the packed rules the server ships to the browser — and the AI contracts, which are large enough to sit in their own directory with deep imports.
Naming is conventional so that the reader never has to guess: <thing>Schema
for a response or an entity, <thing>RequestSchema for a body,
<thing>QuerySchema for query parameters, and an inferred type sitting next to
each one.
export const memberSchema = z.object({ /* ... */ })
export type Member = z.infer<typeof memberSchema>
export const inviteMemberRequestSchema = z.object({ /* ... */ })
export type InviteMemberRequest = z.infer<typeof inviteMemberRequestSchema> Types are inferred from schemas, never declared alongside them. A hand-written interface next to a schema is a second source of truth that compiles happily while diverging.
Contracts first, as a working order
A new or changed API surface starts here rather than arriving here. The sequence is fixed:
- Write or change the schema in
packages/shared/src/and export it. - Validate with it in the route, which throws the shared error envelope on a bad request.
- Parse with it in the UI, so a response that drifted fails loudly at the boundary.
- Parse with it in the CLI, and only then add the command.
Errors are the same envelope everywhere — including validation failures, which is why the route wrapper throws a typed validation error rather than letting the validator emit its own shape. Pagination is one shape everywhere too, so a list endpoint added next year renders in the existing pagination control without a conversation.
The package has no test suite of its own; its schemas are exercised by the web and CLI tests that import them. A schema tested in isolation tells you it parses the fixture you wrote — testing it at the boundary tells you the server and the client agree, which is the property that matters.
The dependency rule, and why it is strict
packages/shared may import zod, its own siblings, and one type-only
import for the ability type. That is the entire list. It may never import
apps/web, and it may never import apps/cli; the CLI in turn never
imports the web application.
Why the direction is enforced. This package has to bundle for the browser and load inside a Node CLI. One import reaching back into the server drags the whole server type graph — and eventually its runtime dependencies — across both boundaries. The symptom is unmistakable and arrives late: a browser bundle that suddenly contains a Postgres driver.
The linter and each package's TypeScript configuration keep the direction honest, so the rule is mechanical rather than a matter of reviewer memory.
The one exception, stated out loud
Database columns holding JSON take their type from a schema in this package, so the shape stored and the shape served cannot diverge. There is a single deliberate exception: the dashboard configuration column takes its type from the analytics library instead, imported type-only on the server side.
The reason is the dependency rule above — shared may only import zod, so it cannot hold that library's type. Its own schema for the column is therefore loose, and the precise type lives where the import is legal. Naming the exception is the point: an undocumented one becomes the precedent for the next.
Private, and staying that way
The package is marked private and has no publish configuration. It is not a library with users; it is the internal agreement between three parts of one repository, and it is meant to be edited freely by whoever forks the kit.
Publishing it would turn every field rename into someone else's breaking change, which is exactly the friction the kit exists to avoid. It travels with your copy of the repository and versions with it.