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
Membershipnone in v1 - every member of an organization sees and edits all of its projects
Logooptional image, set on /settings/project (and on the desktop app's Project screen, by URL) 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: true } in packages/config/src/index.ts - the same file the CLI writes.
Reload. The sidebar workspace switcher, the /settings/project page and its settings tab 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.

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