Skip to content

Monorepo usage

In a monorepo, you usually want one schema, shared by every consumer — web app, mobile app, CLI tools, Node services. use-q is designed for this: the schema is just a .ts module, easy to publish from a workspace package.

my-org/
├─ apps/
│ ├─ web/ # React app, uses @use-q/api-client-react
│ ├─ cli/ # Node CLI, uses @use-q/api-client
│ └─ worker/ # Cloudflare Worker, uses @use-q/api-client
├─ packages/
│ ├─ api-schema/ # The single source of truth
│ ├─ api-spec/ # (optional) OpenAPI 3.x source for codegen
│ └─ ui/ # Shared React components
├─ pnpm-workspace.yaml
└─ package.json

packages/api-schema/ exports schema (and any handcrafted types). Everyone else imports it.

packages/api-schema/package.json
{
"name": "@my-org/api-schema",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"codegen": "use-q-codegen --input ../api-spec/openapi.yaml --output ./src/generated.ts",
"build": "tsup src/index.ts --format esm,cjs --dts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@use-q/api-client": "workspace:*"
}
}
packages/api-schema/src/index.ts
import type { Schema } from "@use-q/api-client";
import { schema as generated } from "./generated";
const facility = (p: { facilityId: string }) => p.facilityId;
// Layer hand-edited tags on top of the codegen output
export const schema = {
...generated,
listPosts: {
...generated.listPosts,
tags: [{ type: "post", facilityId: facility }],
},
createPost: {
...generated.createPost,
invalidatesTags: [{ type: "post", facilityId: facility }],
},
} as const satisfies Schema;
export type { Schema } from "@use-q/api-client";

The whole point of this file: codegen handles the boring 90% (types, paths, params), you sprinkle tags and invalidation hand-typed. Rerunning codegen never touches your tag wiring.

apps/web/src/api/client.ts
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "@my-org/api-schema";
export const api = createApiClient<typeof schema>(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL,
defaultHeaders: () => ({
Authorization: `Bearer ${tokenStore.get() ?? ""}`,
}),
});
export const { useQ, useM, useInfiniteQ, useQClient } = api;
// apps/web/package.json (excerpt)
{
"dependencies": {
"@my-org/api-schema": "workspace:*",
"@use-q/api-client-react": "workspace:*",
"@tanstack/react-query": "^5.0.0",
"react": "^18.0.0"
}
}
apps/cli/src/index.ts
import { createFetcher } from "@use-q/api-client";
import { schema } from "@my-org/api-schema";
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: process.env.API_BASE_URL!,
defaultHeaders: () => ({
Authorization: `Bearer ${process.env.API_TOKEN!}`,
}),
});
const posts = await fetcher.fetch("listPosts", {
pathParams: { facilityId: process.argv[2]! },
});
console.table(posts.map(({ id, title }) => ({ id, title })));

No QueryClient, no React — same schema, same types, same tag-aware fetcher (tags just don’t do anything in this context).

apps/worker/src/index.ts
import { createFetcher } from "@use-q/api-client";
import { schema } from "@my-org/api-schema";
export default {
async fetch(req: Request, env: Env) {
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: env.API_BASE_URL,
fetchFn: fetch,
});
const posts = await fetcher.fetch("listPosts", {
pathParams: { facilityId: env.DEFAULT_FACILITY_ID },
});
return Response.json(posts);
},
} satisfies ExportedHandler<Env>;
  1. Put the OpenAPI spec in packages/api-spec.

    packages/api-spec/openapi.yaml
    openapi: 3.0.3
    info:
    title: My API
    version: 0.1.0
    paths:
    # ...
  2. Run codegen as a prebuild script in api-schema:

    {
    "scripts": {
    "codegen": "use-q-codegen --input ../api-spec/openapi.yaml --output ./src/generated.ts",
    "prebuild": "pnpm codegen"
    }
    }
  3. Wire it into CI so the generated file is always fresh:

    - run: pnpm --filter @my-org/api-schema codegen
    - run: |
    if ! git diff --exit-code; then
    echo "::error::Generated schema is stale. Run pnpm --filter api-schema codegen."
    exit 1
    fi
  4. Layer tags in src/index.ts — never edit src/generated.ts by hand.

See Codegen for CLI flags.

With strict TS, you’ll want a tsconfig.json per package and a root tsconfig.json with references:

// tsconfig.json (root)
{
"files": [],
"references": [
{ "path": "packages/api-schema" },
{ "path": "apps/web" },
{ "path": "apps/cli" },
{ "path": "apps/worker" }
]
}
packages/api-schema/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"composite": true
},
"include": ["src"]
}
apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../../packages/api-schema" }],
"compilerOptions": {
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}

This gives you incremental builds and accurate type-checking across the graph.

  • One schema, no duplication. Resist the urge to copy slices of schema into individual apps. The whole point of a monorepo is that the source of truth is shared.
  • workspace:* everywhere. Both for @my-org/api-schema and for any @use-q/* packages you author.
  • CI: run codegen, then diff. This catches OpenAPI drift before merging.
  • Don’t ship @use-q/api-client-react to non-React apps. The schema package depends only on @use-q/api-client (the framework-agnostic core).