--- 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)