Project Structure
Where the contract, the server, and the clients live, and why the contract sits in its own package.
ts-kizuna has no opinion about your file layout. A single file works. The one thing it needs is a contract that every side of your app can import.
The layout below is the one we use ourselves. It goes from a handful of routes to a few hundred without anything moving. Two shapes cover almost everything:
Monorepo
The API and its consumers are separate apps, and the contract is a package they both import.
Single app
The API and the website are the same app, so the contract is a folder inside it.
Monorepo
my-repo/
├── packages/
│ └── contract/
│ └── src/
│ ├── k.ts # new Kizuna(), exports k
│ ├── tags.ts # Kizuna.tags
│ ├── identities.ts # Kizuna.identity builders
│ ├── routes/
│ │ ├── users.ts # k.routes('users', { ... })
│ │ ├── events.ts
│ │ ├── health.ts
│ │ └── index.ts # re-exports the groups
│ ├── auth.ts # k.auth, who may call what
│ ├── jobs.ts # k.jobs
│ └── contract.ts # k.contract, the object everything reads
├── apps/
│ ├── api/
│ │ └── src/
│ │ ├── server/
│ │ │ ├── server.ts # new KizunaServer(contract)
│ │ │ ├── guards.ts # server.guard per identity
│ │ │ ├── router/
│ │ │ │ ├── users.ts # Router<typeof contract>['users']
│ │ │ │ ├── events.ts
│ │ │ │ ├── health.ts
│ │ │ │ └── index.ts # server.router, composes the groups
│ │ │ ├── jobs.ts # server.jobs
│ │ │ └── api.ts # server.api, what you mount
│ │ └── index.ts # api.mount(app)
│ └── website/
│ └── src/lib/api-client.ts # new KizunaClient(contract)
└── package.jsonWhy the contract is its own package
Your server imports the contract. So does your website, and so does anything else that talks to the API. If it lives inside the API app, every consumer has to reach into that app to get it.
The contract is also plain data, with no server code in it, which is what makes it safe to bundle for a browser. Keeping it in its own package is what preserves that property, because it cannot accidentally pick up a database driver or a Node built-in.
Once it is a package, publishing it is a small step. Teams with API consumers outside the repo push the contract to npm, public or private, so anyone integrating installs one package and gets the routes, the types, and a client that already knows both. It is the same thing your own website imports, handed to someone else.
Splitting the pieces
Each file above does one thing, and the split matters more as the API grows:
k.tsconstructs the surface once. Everything else importskfrom here rather than constructing its own.routes/holds one file per group, each callingk.routes, with anindex.tsre-exporting them socontract.tshas one import to make. See organizing the contract.router/mirrors it exactly. One file per group, each typed with theRoutertype so inputs and responses stay inferred, andindex.tscomposes them withserver.router.auth.tsdeclares who may call what, once, against the whole route tree. It sits beside the routes rather than inside them so a single file answers the question. See auth.api.tsstays small. It is the one place guards, jobs, plugins, and the router come together.
Single app
Not every project needs a monorepo. With Next.js the API and the website are the same app, so the contract can be a folder rather than a package:
my-repo/
└── src/
├── contract/
│ ├── k.ts
│ ├── identities.ts
│ ├── routes/
│ │ ├── users.ts
│ │ ├── events.ts
│ │ ├── health.ts
│ │ └── index.ts
│ ├── auth.ts
│ └── contract.ts
├── server/
│ ├── server.ts
│ ├── guards.ts
│ ├── router/
│ │ ├── users.ts
│ │ ├── events.ts
│ │ ├── health.ts
│ │ └── index.ts
│ └── api.ts
├── lib/
│ └── api-client.ts
└── app/
└── api/[...ts-kizuna]/route.tsThe rule is the same at any size: one contract, imported by both sides, with no server-only code in it.
Next steps
- Building an API covers the contract, router, and mounting in depth
- Adapters covers the setup for each framework