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

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.json

Why 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.ts constructs the surface once. Everything else imports k from here rather than constructing its own.
  • routes/ holds one file per group, each calling k.routes, with an index.ts re-exporting them so contract.ts has one import to make. See organizing the contract.
  • router/ mirrors it exactly. One file per group, each typed with the Router type so inputs and responses stay inferred, and index.ts composes them with server.router.
  • auth.ts declares 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.ts stays 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.ts

The rule is the same at any size: one contract, imported by both sides, with no server-only code in it.

Next steps

On this page