Package Conventions
Monorepo package architecture conventions: types / contracts / core / react with clean boundaries, no duplication, and portable design.
Monorepo Conventions: types / core / react
This document defines the conventions and package architecture for a TypeScript + ESM monorepo that uses:
- Fastify (server)
- OpenAPI (API contracts)
- hey-api (TypeScript client generation)
- Zod (runtime schemas)
- TanStack Query (React data fetching)
- tsup (package builds for npm)
Goal: clean boundaries, no duplication, portable across runtimes, and fast DX in a monorepo while still producing dist-only packages for publishing.
Why this architecture
We want three outcomes at the same time:
- Single source of truth for API shapes (request/response) with runtime validation.
- Apps across the monorepo can import types safely without dragging server/runtime dependencies everywhere.
- Packages compile to clean ESM artifacts for npm (but we can still do fast local dev in the monorepo).
The key idea:
- Domain types are pure TypeScript.
- OpenAPI specs define API contracts (source of truth).
- core provides generated hey-api clients (runtime-agnostic).
- react provides TanStack Query hooks from generated clients.
Package layout
packages/
types/ # pure TS domain types (no zod, no hey-api)
core/ # generated hey-api client (runtime-agnostic)
react/ # react-query hooks from generated clients
apps/
api/ # Fastify server + OpenAPI spec
web/ # Next.js app consuming core/reactDependency direction (strict)
types→ depends on nothingcore→ depends on generated hey-api client codereact→ depends oncoreand generated client code
No reverse dependencies.
Mermaid architecture diagram
flowchart TB
subgraph P[packages]
T[types
(pure TS domain)]
K[core
(generated hey-api client)]
R[react
(TanStack Query hooks)]
end
subgraph A[apps]
API[api
(Fastify + OpenAPI)]
WEB[web
(Next.js)]
end
T --> K
K --> R
API -->|OpenAPI JSON| GEN[hey-api Generator]
GEN -->|Generated Code| K
GEN -->|Generated Code| R
API -->|OpenAPI JSON| DOCS[Scalar UI]
R --> WEB
K --> WEB
WEB -->|HTTP| APIPackage responsibilities
1) packages/types
What goes here
- Domain models and shared non-HTTP types:
User,Wallet,ChainId,Money, etc.
Hard rules
- ✅ TypeScript
type/interfaceonly - ❌ No
zod - ❌ No
hey-api - ❌ No runtime helpers
Example
// packages/types/src/index.ts
export type UserId = string;
export interface User {
id: UserId;
email: string;
}
export type ApiErrorCode =
| "UNAUTHORIZED"
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "INTERNAL_ERROR";
export interface ApiError {
code: ApiErrorCode;
message: string;
requestId?: string;
}2) OpenAPI Specification
The HTTP boundary is defined by the OpenAPI spec.
What goes here
- OpenAPI 3.0 specification file (
apps/api/openapi/openapi.json) - Request/response schemas defined in OpenAPI format
- API metadata (tags, summary, description)
Hard rules
- ✅ OpenAPI 3.0 standard format
- ✅ Can reference domain types conceptually
- ❌ No TypeScript code (spec is JSON/YAML)
- ❌ No server framework code
Pattern: OpenAPI spec as source of truth
The OpenAPI spec defines all API contracts. hey-api generates TypeScript clients from this spec.
{
"openapi": "3.0.0",
"paths": {
"/users/{id}": {
"get": {
"summary": "Get user by id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "User found",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"email": { "type": "string" }
}
}
}
}
}
}
}
}
}
}3) packages/core
The runtime-agnostic client package with generated hey-api clients.
What goes here
- Generated TypeScript client code from OpenAPI spec (via
@hey-api/openapi-ts) createApi({ baseUrl, getAuthToken, getHeaders })wrapper- shared fetch behavior (headers, auth, retries if you want)
Hard rules
- ✅ No React
- ✅ No TanStack
- ✅ OK to be used in Node, Next, Workers (depending on fetch)
- ✅ Generated code lives in
src/gen/directory
Example
// packages/core/src/api.ts
import type { CoreClientOptions } from './config.js'
import { createClient, createConfig } from './gen/client/index.js'
import * as gen from './gen/index.js'
export function createApi(options: CoreClientOptions) {
const config = createConfig({
baseUrl: options.baseUrl,
})
const client = createClient(config)
return {
async healthCheck() {
const [token, extraHeaders] = await Promise.all([
options.getAuthToken?.(),
options.getHeaders?.(),
])
const headers: Record<string, string> = {}
if (extraHeaders) {
Object.assign(headers, extraHeaders)
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const response = await gen.healthCheck({
client,
...(Object.keys(headers).length > 0 && { headers }),
})
if (!response.data) {
throw new ApiError(/* ... */)
}
return response.data
},
}
}// packages/core/src/index.ts
export { createApi } from "./api";
export type { CoreClientOptions } from "./config";4) packages/react
React-only helpers using TanStack Query with generated hooks.
What goes here
- Generated React Query hooks from OpenAPI spec (via
@hey-api/openapi-ts) - React Query hook wrappers
- shared query keys (optional)
Hard rules
- ✅ React-only dependencies live here
- ✅
@tanstack/react-querybelongs here - ✅ Generated code lives in
src/gen/directory
Example
// packages/react/src/hooks/useHealthCheck.ts
import { useQuery } from '@tanstack/react-query'
import { useReactApiConfig } from '../context'
import { healthCheck } from '../gen/index.js'
export function useHealthCheck() {
const { client, getAuthHeaders } = useReactApiConfig()
return useQuery({
queryKey: ['healthCheck'],
queryFn: async () => {
const headers = await getAuthHeaders()
const response = await healthCheck({ client, headers })
if (!response.data) {
throw new Error('Health check failed')
}
return response.data
},
})
}Then in the web app:
import { ReactApiProvider } from '@basilic/react'
import { useHealthCheck } from '@basilic/react'
function App() {
return (
<ReactApiProvider config={{ baseUrl: process.env.NEXT_PUBLIC_API_URL! }}>
<MyComponent />
</ReactApiProvider>
)
}
function MyComponent() {
const { data } = useHealthCheck()
// data is fully typed from OpenAPI spec
}Server implementation (Fastify) matches OpenAPI spec
The server app (apps/api) should:
- Define OpenAPI spec in
openapi/openapi.json - Implement routes to match the OpenAPI spec
- Expose OpenAPI JSON at
/reference/openapi.json - Mount Scalar UI at
/reference
Multi-Language SDK Philosophy
TypeScript consumers use generated clients from @hey-api/openapi-ts for full type safety.
Other languages (Go, Rust, Python) generate SDKs from the same OpenAPI spec when needed.
This gives us:
- ✅ Generated TypeScript clients with full type safety
- ✅ Consistent API contracts across all languages
- ✅ OpenAPI always available for non-TS consumers
See API Contracts for detailed philosophy and implementation strategy.
Mermaid: request flow
sequenceDiagram
participant Web as Next.js (react)
participant Core as core client (generated)
participant API as Fastify API
participant OpenAPI as OpenAPI Spec
Web->>Core: call generated client/hooks
Core->>API: HTTP request (typed)
API->>OpenAPI: validates against OpenAPI spec
API-->>Core: typed response
Core-->>Web: data -> react-query cacheESM + tsup build strategy
We want:
- ESM-first packages
- tsup builds for publishing to npm
- fast monorepo DX (direct imports) without needing to build constantly
Monorepo source exports + prepack for npm
Package package.json (monorepo/dev)
{
"name": "@acme/contracts",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"build": "tsup",
"prepack": "pnpm build && node ../../scripts/prepare-publish.mjs"
}
}tsup config (ESM + d.ts)
// packages/contracts/tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
outDir: "dist",
});Prepack script: switch exports to dist
// scripts/prepare-publish.mjs
import fs from "node:fs";
import path from "node:path";
const pkgPath = path.resolve(process.cwd(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.exports = {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
};
pkg.main = "./dist/index.js";
pkg.types = "./dist/index.d.ts";
pkg.files = ["dist"];
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");Convention:
prepackruns inside each package when publishing, so the script usesprocess.cwd().
Important dev/runtime note
If your server (apps/api) imports workspace packages that export src/*.ts, then Node cannot run that TypeScript unless you:
- run the server with a TS runtime like
tsxin dev, or - switch to Approach B and build packages for dev.
Recommended dev command for Fastify:
{
"scripts": {
"dev": "tsx watch src/server.ts"
}
}Next.js consumption rules
Next.js should transpile workspace packages:
// apps/web/next.config.js
module.exports = {
transpilePackages: [
"@acme/types",
"@acme/contracts",
"@acme/core",
"@acme/react"
],
};This keeps DX smooth without having to prebuild packages.
Import conventions (do this, not that)
Domain types
✅
import type { User } from "@acme/types";API-derived DTO types
✅
import type { UserDTO } from "@acme/contracts";Avoid leaking contracts runtime
- In non-HTTP domain code, prefer importing from
types. - Only import from
contractswhen you are working on API boundary shapes.
Checklist for new endpoints
When you add a new endpoint:
- Update OpenAPI spec in
apps/api/openapi/openapi.json - Regenerate clients:
pnpm --filter @basilic/core genandpnpm --filter @basilic/react gen - Implement route handler in
apps/api/src/routes/ - Use generated client via
packages/core - Use generated React Query hooks via
packages/reactor directly in apps
Example: Health Check Endpoint
The health check endpoint demonstrates the full monorepo pattern:
1. OpenAPI Specification (apps/api/openapi/openapi.json)
{
"paths": {
"/health": {
"get": {
"summary": "Health check endpoint",
"responses": {
"200": {
"description": "Service is healthy",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"now": { "type": "string", "format": "date-time" }
}
}
}
}
}
}
}
}
}
}2. Generate Clients
Run @hey-api/openapi-ts to generate TypeScript clients in packages/core/src/gen/ and packages/react/src/gen/.
3. Server Implementation (apps/api)
// apps/api/src/routes/health.ts
import type { FastifyPluginAsync } from 'fastify'
const health: FastifyPluginAsync = async (fastify) => {
fastify.get('/health', async (_request, reply) => {
return reply.send({
ok: true,
now: new Date().toISOString(),
})
})
}
export default health4. Client Usage (apps/web or other consumers)
import { useHealthCheck } from '@basilic/react'
function MyComponent() {
const { data } = useHealthCheck()
// data is typed as { ok: boolean, now: string } from OpenAPI spec
}OpenAPI Documentation
- OpenAPI JSON available at
GET /reference/openapi.json - Interactive Scalar UI at
GET /reference - Generated from OpenAPI spec
Summary
typesstays clean: no runtime, no API boundary- OpenAPI spec is the API contract: source of truth for all languages
coreis the generated client: runtime-agnostic, generated by hey-apireactis the React layer: TanStack Query hooks, generated by hey-api- ESM everywhere
- tsup builds dist for npm
- monorepo dev can use source exports +
prepackswitch (Approach A)
This architecture stays portable and keeps your boundaries sharp as the codebase grows.
Related Documentation
- Monorepo Structure - High-level monorepo overview
- Code-First APIs - Code-first development approach
- Architecture Overview - All architecture topics