---
title: Structure
description: How the monorepo is laid out, and which dependencies are allowed.
type: conceptual
related:
- /en/docs/philosophy
---
# Structure
A [Turborepo](https://turborepo.com) monorepo: seven apps and twenty shared
packages, plus the CLI that publishes the template.
## Apps
Each app is deployable on its own and owns its own `env.ts`, composing the
environment variables of the packages it uses.
| App | Port | Purpose |
| ----------- | ---- | --------------------------- |
| `app` | 3000 | The authenticated product |
| `web` | 3001 | Marketing, blog and legal |
| `api` | 3002 | Webhooks and scheduled work |
| `email` | 3003 | React Email preview |
| `docs` | 3004 | This documentation site |
| `studio` | 3005 | Prisma Studio |
| `storybook` | 6006 | Component workshop |
Apps do not import one another. Where they need to talk, they do it over HTTP
using the `NEXT_PUBLIC_*_URL` variables — which is also what lets them run on
different hosts.
`apps/docs` is part of the template, not of projects built from it. The CLI
strips it during `init`, along with `packages/cli`, `.gitlab`, `ci` and
`.claude`. A generated project therefore has six apps.
## Packages
Shared code lives in `packages/` and is imported as `@kreogen/`. The
point is to keep integration-shaped code out of the apps, so that swapping an
implementation is a change in one package rather than a search across three
apps.
**They resolve straight to source.** There is no `exports` map and no build
step: `@kreogen/auth/session` is literally `packages/auth/session.ts`, resolved
through a path alias.
That has a consequence worth internalising — **renaming a file is a breaking
API change**. And the alias exists in two places, `packages/typescript-config/nextjs.json`
and `@kreogen/testing`, which must agree. A test fails when they drift, because
nothing else would tell you: TypeScript reports a missing module and explains
nothing about why.
## Packages do depend on each other
They are layered, not isolated. `auth` depends on `database`, `email`,
`rate-limit`, `design-system`, `observability`, `analytics` and `validation`,
and it should — assembling that from scratch in every app is how the pieces
drift apart.
What matters is the *direction*. Two edges are prohibited, both because
allowing them broke something real:
**`design-system` must not depend on `auth`.** The auth components are built
from design-system primitives, so the reverse edge is a cycle. Mounting an auth
provider inside the design system also pulled an auth runtime into the
marketing bundle, which has no session at all.
**`observability` must not depend on `analytics`.** `parseError` is reachable
from client components, and the server analytics client is `server-only`.
Importing it — even behind a dynamic import, since the bundler traces those
too — puts a server-only module in the client graph and fails the build while
typechecking cleanly.
The general rule: **`observability` is a leaf**. Everything logs, so it can
import nothing that logs. Where two packages genuinely need to know about each
other, compose them at the app level instead — each app's `instrumentation.ts`
is exactly that, injecting the analytics error reporter into observability
rather than letting observability reach for it.
## Boundaries
```sh
bun run boundaries
```
Turborepo's [boundaries](https://turborepo.com/docs/reference/boundaries) check
catches workspace violations — importing across package roots without declaring
the dependency. It enforces that a dependency is declared, not that it is
sensible; the two rules above are conventions kept honest by review and by the
build failing.
## Turbo tasks
* `build` depends on `^build` only. Tests are a separate CI job — coupling them
serialised the build behind them and dragged vitest into the Docker image.
* `typecheck` depends on `^build`, because packages need the generated Prisma
client to typecheck at all.
* `lint` is deliberately **not** a turbo task. Biome reads one root config and
covers the repository in a couple of seconds; per-package would cache poorly
for no gain.
`envMode` is `strict`, so a task cannot read an environment variable it has not
declared. That is the point: the declaration is what puts the value in the
cache key. Under the loose default, editing `NEXT_PUBLIC_APP_URL` and
rebuilding returned the previous bundle with the old value still inlined — a
cache hit that produced a wrong artifact.
---
For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)
For an index of all available documentation, see [/llms.txt](/llms.txt)