GenerateSaaS

Projects

The project entity that scopes a user's work inside their account or inside their active organization - ownership, the API, config.projects.enabled, and the useActiveProject hook.

A project is a named container a user switches between inside one account, gated by config.projects.enabled. It holds no members, no roles, and no money of its own: ownership follows tenancy, and who pays never changes.

Rowprojects table: id, name, logo, the two nullable owner columns (userId, organizationId), createdAt, updatedAt
Ownerthe active organization when tenancy.multiTenant is on, otherwise the signed-in user
Seededa Default per owner, from whichever seam the tenancy mode uses: organization creation when multiTenant is on, account creation when it is off - never both. None at all when projects.seedDefault is false
Membershipnone in v1 - every member of an organization sees and edits all of its projects
Logooptional image, set on /settings/project (the same screen in the desktop app) and drawn by the sidebar switcher; falls back to the organization's logo, then to initials
LimitsMAX_PROJECTS_PER_OWNER 100, MAX_PROJECT_NAME_LENGTH 100, MAX_PROJECT_LOGO_LENGTH 2048 (all from @repo/config)

Little else is project-scoped for you: an AI integration connection can be placed in a project, and every other entity keeps the ownership it had. Metering is untouched either way - a project is a scoping seam you build on, never a billing boundary.

Enable it

Scaffold with the projects manifest option, or set projects.enabled to true in packages/config/src/index.ts - the same file the CLI writes.
Reload. The sidebar workspace switcher, the /settings/project page and its run of the settings rail all mount behind that one flag. With tenancy.multiTenant on too, one sidebar row leads with the active project and puts the organization list a tap away.

The table, the API routes and the seeds ship either way, so turning it on later finds a Default already seeded for every owner that has passed its seam.

Skip the Default project

Set projects: { enabled: true, seedDefault: false } when a project is something your users should create themselves. Signup, organization creation and GET /projects then seed nothing, and an owner with no project sees the switcher's New project entry and the settings page's empty state.

seedDefault is read only while enabled is on: with project surfaces off, work can still be scoped to an organization's first project and nothing could create one, so the seed stays. Creating an owner's first project adopts every chat it started without one, in the same transaction, so no chat drops out of the list.

The flag is not the only thing that scopes projects. Enabling tenancy.multiTenant alone already scopes the desktop app's on-device data by an invisible per-organization project, so projects adds the UI rather than the scoping. In a single-tenant app, enabling projects moves desktop automations to the reachable-allowlist mode, where an explicit sign-out (or an expired session) stops every unattended automation on that device.

API

Mounted under the API base at /projects, always - the flag gates UI, never routes. Every handler requires a session, and every one but /reachable scopes to the caller's resolved owner.

RouteContract
GET /projects200 { projects }, oldest first by createdAt. Seeds the owner's Default on first read - best effort, skipped when seedDefault is off, and skipped entirely when multi-tenant with no active organization, which answers 200 { projects: [] }
POST /projects{ name } trimmed, 1-100 chars -> 201 { project }
PUT /projects/:id{ name, logo? } -> 200 { project }; an omitted logo is left alone, an empty one clears it; a row of another owner is 404
DELETE /projects/:id200 {}; a row of another owner is 404, unless the caller is at the one-project floor, which answers LAST_PROJECT for any id
GET /projects/reachable200 { ids } - the ids a correctly configured client can surface and stop, across every organization the caller belongs to rather than the active one
Error codeHTTPRaised when
LAST_PROJECT400The delete would leave the owner with none
PROJECT_LIMIT400The create would pass 100 projects for this owner
NO_ACTIVE_ORGANIZATION400A mutation while multi-tenant with no active organization. GET /projects never raises it - it answers 200 { projects: [] } for the same session

Translate the codes client-side; @repo/config exports each one so the API, the web app and the desktop app agree on the string.

Reading the active project

useActiveProject() (apps/web/hooks/use-active-project.tsx) is the plumbing your own features scope by. Its provider sits above the dashboard sidebar; the pointer persists per (user, organization) in localStorage and refetches when the active organization changes.

const { projects, activeProject, activeProjectId, setActiveProject, refresh, isPending } =
  useActiveProject();

With the flag off it returns the same shape, empty and settled - projects: [], activeProjectId: null, isPending: false - so a component can read it unconditionally.

On this page