Route Ink is a convention-driven code generator for monorepos that follow a specific style. It ships three independent tools in one package:
- CLI — generates fully typed React Query hooks from your Fastify route files.
- Prisma generator — generates Zod schemas and TypeScript types from your
schema.prisma. - Cube.js base generator — generates hidden Cube.js base schemas from Prisma's DMMF and Prisma
///comments.
Both are opinionated and tuned to a particular set of conventions. Use either, both, or neither. This is a personal-style tool — no support guarantees, PRs may be ignored.
Route Ink is published as a private package on GitHub Packages. Consuming it requires a one-time auth setup.
1. Add .npmrc to your consumer repo root (commit this file):
@codevuk:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
2. Provide a GitHub PAT with read:packages scope via the NODE_AUTH_TOKEN env var.
- Local development: export it from your shell profile.
- GitHub Actions CI: the built-in
secrets.GITHUB_TOKENworks (the workflow needspermissions: packages: read). - Other CI: store a PAT as a secret and inject it as
NODE_AUTH_TOKEN.
3. Install:
pnpm add -D @codevuk/route-inkThis installs three binaries into node_modules/.bin/:
route-ink— the CLIroute-ink-prisma-generator— the Prisma generator (referenced fromschema.prisma)route-ink-cube-sync-generator— the Cube.js base-schema Prisma generator (referenced fromschema.prisma)
Given *.route.ts files with schema definitions, the CLI generates:
- Query hooks for
GET - Mutation hooks for
POST,PUT,PATCH,DELETE - Utility files (
injectParams,serializeSearchQuery,buildQueryKey,QueryError)
Generated hooks are typed with your schema package and parse responses with Zod where response schemas exist.
The CLI is convention-driven. It expects:
- Fastify instance is named
fastify - Route files end with
.route.ts - Route definitions use
fastify.<method>(path, options, handler)style whereoptions.schemais an object literal operationIdexists in each route schema- Shared schemas are imported from a single package (configured via
schemaPackage) - Config file is named
routeink.jsonand lives in the current working directory
If your codebase does not follow these conventions, parsing can skip endpoints.
Create routeink.json in the project where you run the command:
{
"routesDir": "../api/src/routes",
"outputDir": "./src/generated",
"name": "api-client",
"schemaPackage": "@workspace/schemas",
"exportQueryOptions": false
}| Field | Default | Description |
|---|---|---|
routesDir |
../api/src/routes |
Where Route Ink scans for *.route.ts |
outputDir |
(required) | Destination parent directory |
name |
api-client |
Output folder name inside outputDir |
schemaPackage |
@workspace/schemas |
Package to import schema symbols from |
exportQueryOptions |
false |
Also export a queryOptions factory per query (see below) |
pnpm route-ink generateValidation errors stop generation. CLI output uses colored status badges and table-formatted warnings/errors.
<outputDir>/<name>/
index.ts
queries.ts
mutations.ts
endpoints/
index.ts
...generated endpoint hooks
util/
buildQueryKey.ts
injectParams.ts
serializeSearchQuery.ts
QueryError.ts
index.ts
Queries (GET): basic, params only, query only, query + params.
Mutations (POST, PUT, PATCH, DELETE): basic, body only, params only, body + params. Mutation responses are optional.
Wrap your tree with the generated RouteInkProvider and supply your own Axios instance:
import axios from "axios";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouteInkProvider } from "./generated/api-client/util";
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL });
const queryClient = new QueryClient();
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<RouteInkProvider axios={api}>{children}</RouteInkProvider>
</QueryClientProvider>
);
}A GET endpoint with operationId: "getUsers" becomes useGetUsersSuspenseQuery:
const { data } = useGetUsersSuspenseQuery();
useGetUserByIdSuspenseQuery({ params: { userId: "42" } });
useSearchUsersSuspenseQuery({ query: { page: 1, search: "sam" } });A mutation endpoint with operationId: "createUser" becomes useCreateUserMutation:
const createUser = useCreateUserMutation();
createUser.mutate({ body: { name: "Sam", email: "sam@example.com" } });
updateUser.mutate({ params: { userId: "42" }, body: { name: "Updated" } });With "exportQueryOptions": true, every query file additionally exports a queryOptions factory. It is a plain function (not a hook) taking your Axios instance, so it also works outside React — router loaders, prefetching, queryClient.ensureQueryData:
import { getUserQueryOptions } from "./generated/api-client";
// In a route loader
await queryClient.ensureQueryData(getUserQueryOptions(api, { params: { userId: "42" } }));
// With useQuery / useQueries inside a component
const { data } = useQuery(getUserQueryOptions(api, { params: { userId: "42" } }));The factory is named after the operationId with the first letter lowercased plus a QueryOptions suffix (GetUser → getUserQueryOptions). The generated suspense hook consumes the same factory internally, so query keys and fetch logic stay in one place.
Configuration file not found: ensurerouteink.jsonexists in the current directory.Invalid configuration: check config keys and value types.- Missing generated endpoint: verify file naming, Fastify instance name (
fastify), andoperationIdpresence. - Missing schema imports in output: ensure route schemas reference symbols imported from
schemaPackage.
Generates one Zod schema file per Prisma model and enum, plus barrel re-exports. Designed for monorepos where Prisma lives in one package and the Zod schemas are consumed from another.
// schema.prisma
generator zod {
provider = "route-ink-prisma-generator"
output = "./generated"
modelOutputDir = "../../../schemas/src/zod/models"
enumOutputDir = "../../../schemas/src/zod/enums"
}Then run prisma generate. The output field is required by Prisma but is only used as the anchor for resolving modelOutputDir and enumOutputDir — nothing is written to it. Your Prisma package stays clean.
For each model: a Zod object schema, a scalar-fields enum, and a derived TypeScript type. Only scalar and enum fields are emitted — object relations (@relation) are skipped. Foreign-key scalars (authorId, etc.) are still included since they are scalar fields on the model.
For each enum: a Zod enum schema and a derived type.
Plus a barrel index.ts in each output directory and — when models and enums live in different directories — a top-level barrel at their common ancestor.
Example output for model User { ... role: Role } and enum Role { ... }:
// user.model.ts
import { z } from "zod/v4";
import { RoleSchema } from "../enums/index.js";
export const UserSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
role: RoleSchema,
createdAt: z.coerce.date(),
});
export const UserScalarFieldsSchema = z.enum([
"id",
"email",
"name",
"role",
"createdAt",
]);
export type UserType = z.output<typeof UserSchema>;// role.enum.ts
import { z } from "zod/v4";
export const RoleSchema = z.enum(["ADMIN", "USER", "MODERATOR"]);
export type RoleType = z.output<typeof RoleSchema>;All options go in the generator block in schema.prisma. All are optional unless noted.
| Option | Type | Default | Description |
|---|---|---|---|
output |
string | (required by Prisma) | Anchor for resolving relative modelOutputDir / enumOutputDir. Not written to. |
modelOutputDir |
string | . |
Where model files go. Relative to output, or absolute. |
enumOutputDir |
string | . |
Where enum files go. Relative to output, or absolute. |
modelFileNamingStyle |
string | [model-kebab].model.ts |
File naming pattern for models. |
enumFileNamingStyle |
string | [enum-kebab].enum.ts |
File naming pattern for enums. |
modelSchemaNaming |
string | [Model]Schema |
Exported Zod schema variable name. |
enumSchemaNaming |
string | [Enum]Schema |
Exported Zod enum schema variable name. |
modelTypeNaming |
string | [Model]Type |
Exported TypeScript type alias name. |
enumTypeNaming |
string | [Enum]Type |
Exported TypeScript type alias name. |
nullStrategy |
"null" | "nullish" |
"null" |
Whether optional Prisma fields use .nullable() or .nullish(). |
bigIntStrategy |
"string" | "bigint" |
"string" |
How to map BigInt. |
bytesStrategy |
"string" | "uint8array" |
"string" |
How to map Bytes. |
importStyle |
"esm" | "cjs" |
"esm" |
ESM appends .js to relative imports; CJS does not. |
topLevelBarrel |
boolean | true |
When model and enum dirs differ, emit a barrel at their common ancestor. |
*FileNamingStyle, *SchemaNaming, and *TypeNaming options support these tokens. Inputs come from the Prisma model/enum name (always PascalCase).
| Token | Casing | Example: UserStatus |
|---|---|---|
[Model] / [Enum] |
PascalCase | UserStatus |
[model] / [enum] |
camelCase | userStatus |
[MODEL] / [ENUM] |
UPPER_SNAKE_CASE | USER_STATUS |
[model-kebab] / [enum-kebab] |
kebab-case | user-status |
Acronyms are preserved as units (e.g. HTTPLog → httpLog / HTTP_LOG / http-log).
| Prisma | Zod | Notes |
|---|---|---|
String |
z.string() |
|
Int |
z.number().int() |
|
Float |
z.number() |
|
Boolean |
z.boolean() |
|
DateTime |
z.coerce.date() |
Always coerced. |
Json |
z.any() |
|
Decimal |
z.string() |
|
BigInt |
z.string() or z.bigint() |
Per bigIntStrategy. |
Bytes |
z.string() or z.instanceof(Uint8Array) |
Per bytesStrategy. |
String[] |
z.array(z.string()) |
List fields wrap in z.array(...). |
String? |
z.string().nullable() |
Optional fields per nullStrategy. |
| Enum field | The configured enum schema name | e.g. RoleSchema. |
With defaults (modelOutputDir = ".", enumOutputDir = "."), files live side-by-side and share one barrel:
<output>/
index.ts # combined: all models + enums
user.model.ts
role.enum.ts
With separate model and enum directories (typical monorepo case):
packages/schemas/src/zod/
index.ts # top-level barrel (when topLevelBarrel = true)
models/
index.ts
user.model.ts
enums/
index.ts
role.enum.ts
Consumers can then import from any level:
import { UserSchema, RoleSchema } from "@workspace/schemas/zod"; // top-level
import { UserSchema } from "@workspace/schemas/zod/models"; // models only
import { RoleSchema } from "@workspace/schemas/zod/enums"; // enums only@codevuk/route-ink must be installed (and authenticated against GitHub Packages) before prisma generate runs. Typical GitHub Actions workflow:
permissions:
packages: read
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://npm.pkg.github.com
- run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm --filter db exec prisma generateThe default GITHUB_TOKEN carries read:packages permission when permissions.packages: read is set in the workflow.
With Turborepo, declare the install dependency so prisma generate runs after all installs:
No special handling is required for multi-file Prisma schemas (prismaSchemaFolder) — Prisma merges them into one DMMF before the generator sees it.
Generates hidden Prisma-backed Cube.js base cubes from Prisma's resolved DMMF. Use that generated layer with manual public cubes that extends the base cube, so custom measures such as revenue_ex_gst, avg_order_value, or cohort metrics stay human-owned.
The generator does not read or patch hand-authored Cube YAML files. Manual cubes are only for query-facing choices Prisma cannot know: custom measures, custom dimensions, public exposure, and Cube-specific modelling.
// schema.prisma
generator cubeSchema {
provider = "route-ink-cube-sync-generator"
output = "./generated"
// This directory is fully owned by route-ink.
generatedCubeModelDir = "../../apps/cube-core/model/generated/cubes"
}Then run prisma generate. The output field is required by Prisma but is not written to. generatedCubeModelDir is resolved relative to schema.prisma unless it is an absolute path.
Route Ink writes one hidden base cube per Prisma model:
apps/cube-core/model/generated/cubes/
order_base.yml
customer_base.yml
subscription_base.yml
Generated base cubes are deliberately mechanical:
sql_tablefrom Prisma's resolved table name- cube
descriptionandmeta.ai_contextfrom Prisma model documentation - scalar and enum dimensions from Prisma fields
- member-level
public: truefor generated dimensions, unless a Prisma annotation overrides it - enum
meta.enumvalues - basic relation joins when there is a single unambiguous cube name
- generated
meta.relationshipsfor those relation joins - a default
countmeasure public: false
Then keep the query-facing cube manual:
cubes:
- name: order
extends: order_base
public: true
dimensions:
- name: quarter_number
sql: "({CUBE}.week_number - 538) / 13 + 43"
type: number
measures:
- name: revenue_ex_gst
sql: "({CUBE}.total_price_paid + {CUBE}.discount_amount) / 1.15"
type: sumBecause the base cube is hidden, the manual cube should set public: true explicitly. Generated dimensions set member-level public: true so they remain visible when inherited by a public manual cube. Annotate sensitive Prisma fields before moving a manual cube to extends.
Generated files are compared against the Prisma schema and rewritten when out of date. Route Ink does not delete stale generated files yet; remove obsolete generated files manually after table renames or drops.
Generated base cubes can read Cube-specific annotations from Prisma model and field documentation. Model-level annotations become generated cube metadata. Plain field /// comments become generated dimension descriptions; field-level @cube.* lines control mechanical dimension output.
Model-level annotations:
/// @cube.description One row per customer.
/// @cube.ai_context Use this cube for customer base, acquisition, status, and lifetime box counts.
model Customer {
}Field-level annotations:
/// Customer email snapshot. Direct customer PII.
/// @cube.visibility pii_export
customerEmail String? @map("customer_email")
/// @cube.name executed_sql
/// @cube.description Raw SQL generated by the MCP tool call.
/// @cube.ai_context Internal diagnostic payload. Ignore for business queries.
sql String?| Annotation | Description |
|---|---|
@cube.name <name> |
Override the generated dimension name. Useful for reserved member names such as sql. |
@cube.description <text> |
Override the generated dimension description, or set generated cube description when used on a model. |
@cube.ai_context <text> |
Add meta.ai_context to the generated dimension, or generated cube when used on a model. |
@cube.public true |
Mark the generated dimension public. |
@cube.public false |
Hide the generated dimension. |
@cube.visibility pii_export |
Emit the PII export visibility expression. |
Run Prisma generate:
pnpm --filter db exec prisma generateThe Cube generator rewrites out-of-date generated base cubes. It does not modify manual public cubes.
| Option | Type | Default | Description |
|---|---|---|---|
output |
string | (required by Prisma) | Required generator output placeholder. Not written to. |
generatedCubeModelDir |
string | (required) | Owned directory where hidden generated base cubes are written. Relative to schema.prisma, or absolute. |
generatedCubeNameSuffix |
string | _base |
Suffix for generated base cube names and file names. |
generatedCubeSqlSchema |
string | public |
SQL schema prefix used in generated sql_table values. |
- Missing enum metadata after generate: ensure the field is an enum in Prisma and the generated base cube is imported by Cube.
- Missing relationship metadata after generate: ensure the relation is represented unambiguously in Prisma. Multiple relations to the same target table are skipped and should be modelled manually with alias cubes.
- Stale generated files after table renames or drops: remove obsolete generated files manually; Route Ink rewrites current model files but does not delete old files yet.
pnpm install
pnpm build # builds dist/
pnpm dev # watch mode
pnpm test # vitestTo test against a real consuming project without publishing:
pnpm link --global # from this repoThe route-ink-prisma-generator bin is then available globally. Unlink when done:
pnpm unlink --global route-inkroute-ink generate # generate Fastify → TanStack hooks
route-ink --help
prisma generate # runs configured Prisma generators