Introduction

OverviewPhilosophyStructureUpdatesFAQ

Usage

Other

The standard

Structure

How the monorepo is laid out, and which dependencies are allowed.

A Turborepo 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.

AppPortPurpose
app3000The authenticated product
web3001Marketing, blog and legal
api3002Webhooks and scheduled work
email3003React Email preview
docs3004This documentation site
studio3005Prisma Studio
storybook6006Component 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/<name>. 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

bun run boundaries

Turborepo's 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.