feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
---
title: Active organization
description: Set and switch the current organization context within your application.
url: /docs/web/organizations/active-organization
---
# Active organization
The active organization is tracked based on the **URL slug** and the **session state**. We made it **as simple as possible** to use, introducing our custom hooks and an abstraction to sync it both ways.
Below you can find more details about how to access the active organization across different contexts in your application.
You can customize the behavior to your needs—for example, to restrict users to at most one organization at a time.
## Server component
You have two separate ways to access the active organization of the currently logged-in user on the server:
* from the URL slug (organization-scoped routes)
* from the session (when no slug is present or you don't want to use it)
We recommend always using the URL slug when you're doing something inside an organization-scoped route. This keeps the URL as the source of truth and works seamlessly with SSR and caching.
```tsx title="page.tsx"
import { getOrganization } from "~/lib/auth/server";
export default async function Page({
params,
}: {
params: Promise<{
organization: string;
}>;
}) {
const organization = (await params).organization;
const activeOrganization = await getOrganization({ slug: organization });
return <>{activeOrganization?.name}</>;
}
```
Alternatively, you can use the session to access the active organization. This reads `session.activeOrganizationId` and resolves the organization by its stable ID.
```tsx title="page.tsx"
import { getOrganization, getSession } from "~/lib/auth/server";
export default async function Page() {
const { session } = await getSession();
const activeOrganization = await getOrganization({
id: session.activeOrganizationId,
});
return <>{activeOrganization?.name}</>;
}
```
Be aware that sometimes you might encounter synchronization issues between the URL slug and the session, for example when a user opens multiple tabs to different organizations. More on this in the [Edge cases](#edge-cases) section.
## Client component
On the client side, we designed a dedicated hook to access the active organization - `useActiveOrganization`. It's a simple wrapper around the API that returns the active organization based on the URL slug or the session. It also helps keep the state in sync with the server session.
```tsx title="client.tsx"
"use client";
import { useActiveOrganization } from "~/lib/hooks/use-active-organization";
export default function ClientComponent() {
const { activeOrganization, activeMember } = useActiveOrganization();
return (
<>
<p>{activeOrganization?.name}</p>
<p>{activeMember?.role}</p>
</>
);
}
```
Using the hook is recommended over direct API calls, as it will keep the state in sync with the server session.
It also returns the active member of the active organization, so you can access the user's role and other member-specific data.
## API route
To access the active organization data in an API route, you can read it from the session that is appended to the context when you use [authentication middleware](/docs/web/api/protected-routes).
```ts title="action/router.ts"
export const actionRouter = new Hono().post("/", enforceAuth, async (c) => {
const organizationId = c.var.user.activeOrganizationId;
const organization = await getOrganization({ id: organizationId });
return c.json(organization);
});
```
Although it's the simplest way, we recommend directly passing the `organizationId` together with the payload when you need to perform an action.
```ts title="action/router.ts"
export const actionRouter = new Hono().post(
"/",
enforceAuth,
validate(
"json",
z.object({
organizationId: z.string(),
/* rest of the payload */
}),
),
async (c) => {
const { organizationId, ...payload } = c.req.valid("json");
const organization = await getOrganization({ id: organizationId });
return c.json(await performAction(organization, payload));
},
);
```
This ensures that the action is performed on the correct organization, even if the user has multiple organizations open in different tabs. See [Edge cases](#edge-cases) for more details.
## Edge cases
* **Expected and harmless:** Short periods where the URL slug and server session differ can happen (for example, with multiple tabs or quick switching). The active tab always treats the slug as the source of truth and the session catches up.
* **Multiple tabs:** Each tab maintains its own org context from its slug. As you switch focus, the shared session updates; brief divergence is normal and safe.
* **Rapid switching/slow network:** During fast navigation or poor connectivity, you may momentarily see the previous org while the session updates. Show a small loading state; cancel in-flight requests tied to the old org.
* **Missing/invalid slug:** If the slug is missing or invalid, we fall back to the sessions `activeOrganizationId` or redirect to a safe default.
* **Access or permission changes:** If a user loses access to the org theyre viewing, the data is cleared from the session and the user is redirected to a valid organization or personal dashboard.
<Callout type="warn" title="Invalidation">
Whenever the active organization changes, the server session is updated and the client is redirected to the new organization scope.
All caches keyed by organization are invalidated to avoid leaking data between organizations.
</Callout>

View File

@@ -0,0 +1,93 @@
---
title: Data model
description: Entities and relationships for organizations and multi-tenancy.
url: /docs/web/organizations/data-model
---
# Data model
Our multi-tenant model is organized around the concept of an **organization**. An organization represents a single tenant and is the primary boundary for data isolation, access control, and routing.
Users can belong to multiple organizations through a membership. Invitations let organization admins onboard new members by email with a specific role.
<OrganizationsDbFlow />
## Entities
### Organization
The tenant. Stores human-friendly `name`, unique `slug` (used in URLs and lookups), optional `logo`, and optional `metadata` for extensibility (feature flags, billing context, UI preferences, etc.). `createdAt` provides auditability. The `slug` is globally unique to keep URLs stable and predictable.
### User
The identity of a person. Users are global and can join many organizations. Account-level fields (e.g., `name`, `email`, verification, avatar, security flags) live here.
<Callout type="warn">
A user's application-wide properties (like a global `role` or moderation flags) are distinct from their per-organization role.
</Callout>
### Member (Membership)
The join between a `user` and an `organization`. This is where multi-tenancy permissions are enforced. Each membership stores the `role` the user holds in that specific organization (for example, `member`, `admin`).
Memberships include timestamps for auditing and can be cascaded when a user or organization is removed.
### Invitation
Represents an invite to join an organization by `email` with an intended `role`. It includes `status` (e.g., pending, accepted, revoked), `expiresAt`, and `inviterId` for traceability.
On acceptance, an invitation creates a corresponding membership if one does not already exist.
## Relationships and constraints
<Accordions type="multiple">
<Accordion title="Many-to-many">
Users and organizations are related many-to-many through memberships. A user
can join multiple organizations; an organization has multiple members.
</Accordion>
<Accordion title="Uniqueness">
We keep `organization.slug` unique across the system to ensure
consistent routing and discoverability. Within a single organization, each
`userId` should only appear once in memberships; enforce this
at the application layer or with a composite unique index
`(organizationId, userId)`.
</Accordion>
<Accordion title="Cascades">
* Deleting an organization removes its dependent memberships and invitations.
* Deleting a user removes their memberships and invitations.
These cascades preserve referential integrity and prevent orphaned records.
</Accordion>
</Accordions>
## Tenancy and isolation
### Tenant separator
`organizationId` is the tenant key. All tenant-scoped data should either live under the organization or reference it directly. Every read/write path in the application should be constrained by the current `organizationId`.
### Query guardrails
Derive the active `organizationId` from authenticated context (session or URL slug → lookup → id). Apply `organizationId` filters at the repository/service layer to avoid crosstenant reads. Add composite indexes that include `organizationId` on frequently queried relations.
### Isolation level
All organizations share the same database and schema, separated by `organizationId`. This keeps operations simple and costeffective. If stricter isolation is needed, evolve toward schemapertenant or databasepertenant with care, as operational overhead increases.
<Callout title="Rename organizations">
The term "organizations" is used throughout the starter kit to identify a group of users. However, depending on your application's needs, you might want to represent these groups with a different name, such as "Teams" or "Workspaces."
If that's the case, we suggest retaining "organization" as the internal term within your codebase (to avoid the complexity of renaming it everywhere), while customizing the UI labels to your preferred terminology. To do this, simply update all user-facing instances of "Organization" in your interface to reflect the term that best fits your application.
</Callout>
## Lifecycle flows
* **Create organization**: Create an organization (with `name`, `slug`, optional `logo`/`metadata`) and immediately create a membership for the creator with an elevated role (commonly `owner`).
* **Invite member**:
1. Admin creates an invitation specifying `email` and intended `role`.
2. The invite is sent by email with an expiring token.
3. On acceptance, if the user exists they are added as a member; otherwise they register and then join.
4. Handle idempotency so repeated accepts dont duplicate memberships.
* **Leave or remove**: Members can leave an organization and admins can remove members. The policy that "at least one owner must remain" is enforced at the application layer.

View File

@@ -0,0 +1,92 @@
---
title: Invitations
description: Send, track, and accept organization invites.
url: /docs/web/organizations/invitations
---
# Invitations
You can invite teammates **by email** to join an organization straight from your organization settings.
Acceptance is frictionless: we verify the invite, create (or reuse) the membership with the intended role, and activate the organization in the user's session.
The implementation is based on the [Better Auth plugin](https://www.better-auth.com/docs/plugins/organization) and designed to drive engagement, minimize back-and-forth and keep admins in control.
![Invitations list](/images/docs/web/organizations/invitations/list.png)
## Model
As we can see inside our [data model](/docs/web/organizations/data-model), an invitation targets an `email`, carries the intended `role`, records the `inviterId`, and is scoped to an `organizationId`.
```ts
export const invitation = pgTable("invitation", {
id: text().primaryKey(),
organizationId: text()
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text().notNull(),
role: text(),
status: text().default("pending").notNull(),
inviterId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp().defaultNow().notNull(),
expiresAt: timestamp().notNull(),
});
```
The invitations expire at `expiresAt` to keep links shortlived.
## Status
An invitation can be in one of three states:
* **Pending**: created/sent, awaiting acceptance.
* **Accepted**: verified; membership created or reused.
* **Rejected**: manually invalidated or removed via cascades.
<Callout>
Expiration is controlled by `expiresAt` (not a separate status). After this timestamp, the link is invalid and should be resent.
</Callout>
## Flow
1. Admin creates an invite with `email` and `role`. The `organizationId` is inferred from the context.
2. System generates a signed, single-use token bound to the invite and `expiresAt` and sends a CTA link.
3. Recipient opens the link; we verify the token and email.
4. On success, we proceed to acceptance.
## Onboarding
### Existing user
After verification, we create (or reuse) a membership with the invited role and set the active organization in the session.
![Join organization prompt](/images/docs/web/organizations/invitations/join.png)
### New user
We attach the invite context to signup; after registration, we create the membership and activate the organization - no detours required.
![Invitation disclaimer](/images/docs/web/organizations/invitations/sign-in-disclaimer.png)
You can fully customize the invitation flow to fit your organization's needs. For example, you can add extra onboarding steps, capture additional user information, or implement advanced verification logic as part of the invite process.
The system is designed to be extensible—tailor it to match your team's requirements and user experience preferences.
## Automatic invalidation
An invitation is automatically revoked in the following scenarios:
* **The user accepts the invitation:** Once accepted, the token becomes invalid.
* **The user changes their email address:** To prevent misuse, any changes to the associated email automatically invalidate the token.
* **The user deletes their account:** Invitations linked to a deleted account are revoked to maintain data integrity.
This ensures that invitations remain secure and aligned with the current state of user accounts.
## Invitation management
Admins of the organization and [super admins](/docs/web/admin/overview) can manage invitations via a dedicated section in the dashboard, where they can:
* View the status of all invitations (`pending`, `accepted`, `rejected`).
* Resend invitations who did not respond.
* Revoke invitations if they were sent to the wrong email or are no longer needed.
* Adjust the role of an invitation if not yet accepted

View File

@@ -0,0 +1,79 @@
---
title: Overview
description: Learn how to use organizations/teams/multi-tenancy in TurboStarter.
url: /docs/web/organizations/overview
---
# Overview
Organizations let you build teams and multi-tenant SaaS out of the box, which is a widely used pattern, especially in a [B2B](https://en.wikipedia.org/wiki/Business-to-business) apps. Users can create organizations, invite teammates, assign roles, and seamlessly switch between workspaces.
<Callout title="What is multi-tenancy?">
[Multi-tenancy](https://www.ibm.com/think/topics/multi-tenant) is a software architecture pattern where a single instance of an application serves multiple tenants, each with its own data and configuration.
</Callout>
The feature is mostly powered by the [Better Auth organization plugin](https://www.better-auth.com/docs/plugins/organization) and integrates with TurboStarter's API, routing, data layer, and UI components. This allows you to share most of the code between the web app, [mobile app](/docs/mobile/organizations/overview), and [extension](/docs/extension/organizations).
<ThemedImage light="/images/docs/web/organizations/multi-tenancy/light.png" dark="/images/docs/web/organizations/multi-tenancy/dark.png" alt="Architecture" width={1375} zoomable height={955} />
## Architecture
TurboStarter uses a pragmatic multi-tenant architecture:
* **Tenant context** lives in the session as the active organization ID (derived from the user's selection or defaults). Server handlers read this context to enforce scoping.
* **Data scoping** is performed via `organizationId` on tenant-owned tables and guard clauses in queries. Background tasks and API routes receive the same context.
* **Authorization** combines tenant scoping with role checks. We separate “can access this tenant?” from “can perform this action within the tenant?”.
* **Extensibility**: add new tenant-bound entities by including `organizationId` and using the provided helpers to read the active organization.
This keeps data isolated per organization while remaining simple to reason about and customize.
<Callout>
You can restrict who can create organizations, perform actions within it, and hook into
lifecycle events using our API.
Check dedicated [Data model](/docs/web/organizations/data-model), [RBAC](/docs/web/organizations/rbac) and [Invitations](/docs/web/organizations/invitations) sections or direct [Better Auth docs](https://www.better-auth.com/docs/plugins/organization) for more details.
</Callout>
## Concepts
To effectively use multi-tenancy in your app, we introduced a few core concepts that define how the whole system works:
| Concept | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| **Organization** | A workspace that owns resources and settings, acting as an isolated tenant. |
| **Member** | A user assigned to an organization. |
| **Role** | Access level within an organization (see [RBAC](/docs/web/organizations/rbac)). |
| **Invitation** | Email request to join an organization (see [Invitations](/docs/web/organizations/invitations)). |
| **Active organization** | The currently selected organization in a user's session, used to scope data and permissions. |
These concepts provide the building blocks for flexible team management and secure, multi-tenant SaaS applications.
## Development data
In development, TurboStarter automatically [seeds](/docs/web/installation/commands#seeding-database) some example data when you [setup services](/docs/web/installation/commands#setting-up-services):
* One organization is created by default.
* All default roles are created and assigned within that organization.
* Sample invitations are generated so you can test the invite flow.
You can safely experiment with these sample organizations, roles, and invitations to understand multi-tenancy features - [reset](/docs/web/installation/commands#resetting-database) or [reseed](/docs/web/installation/commands#seeding-database) anytime to return to the default state.
The default credentials for demo users can be customized using the `SEED_EMAIL` and `SEED_PASSWORD` environment variables.
<Callout type="error" title="Never run in production">
The default development data and setup are intended for local development and
testing only. **Never** use these seeds or configurations in a production
environment - they are insecure and may expose sensitive functionality.
</Callout>
## Customization
You have flexibility to adapt organizations to fit your product. For example, you might rename labels (such as Organization to *Team* or *Workspace*), and update the UI copy accordingly.
You can adjust the available [roles and permissions](/docs/web/organizations/rbac) to suit your access model.
The [invitation flow](/docs/web/organizations/invitations) can be customized, including how verification, onboarding, or metadata capture work.
You may also want to introduce tenant-specific policies, like usage limits, feature flags, or billing rules.
Feel free to check how to configure all of these features in the dedicated sections below.

View File

@@ -0,0 +1,97 @@
---
title: RBAC (Roles & Permissions)
description: Manage roles, permissions, and access scopes.
url: /docs/web/organizations/rbac
---
# RBAC (Roles & Permissions)
Role-based access control (RBAC) lets you define who can do what in an organization.
<Callout title="New to RBAC?">
If you're new to the RBAC concept, a simple mental model is:
* Users belong to organizations.
* Users get roles.
* Roles map to permissions on resources.
</Callout>
In TurboStarter, we primarily rely on the [Better Auth plugin](https://www.better-auth.com/docs/plugins/organization) for the heavy lifting - roles, permissions, teams, and member management - while handling critical logic with our own code.
This provides a flexible access control system, letting you control user access based on their role in the organization. You can also define custom permissions per role.
<Callout title="Everything is configured out of the box!">
TurboStarter ships with the default RBAC system configured out of the box. This setup may be enough if you're not planning a very complex access control system, but you can also easily customize it to your needs.
It also includes [protecting routes](/docs/web/api/protected-routes) that users with specific roles can access by adding custom middlewares and disabling certain actions in the UI.
</Callout>
## Roles
Roles are named bundles of permissions. Keep them few and well-defined. By default, we have the following roles:
```ts
const MemberRole = {
MEMBER: "member",
ADMIN: "admin",
OWNER: "owner",
} as const;
```
A user can have multiple roles in an organization. For example, a user can be a member and an admin (if it makes sense for your application).
<Callout type="warn" title="Don't confuse organization admin with super admin">
The organization's `admin` role is **different** from the user's global `admin` role.
The organization `admin` governs permissions only inside the organization, whereas the global `admin` controls access to the [super admin dashboard](/docs/web/admin/overview).
</Callout>
To create additional roles with custom permissions, see the [official documentation](https://www.better-auth.com/docs/plugins/organization#create-access-control) for more details.
## Permissions
Permissions represent what actions a role can perform on which resources. To check if the current user has permission to perform an action, you can use the `hasPermission` function.
```ts
const canCreateProject = await authClient.organization.hasPermission({
permissions: {
project: ["create"],
},
});
```
Or, if you're performing the check on the server, you can use the `hasPermission` function from the `auth.api` object.
```ts
await auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
project: ["create"], // This must match the structure in your access control
},
},
});
```
Once your roles and permissions are defined, you can avoid server checks (e.g., to reduce API calls) by using the client-side `checkRolePermission` function.
```ts
const { activeMember } = useActiveOrganization();
const canUpdateProject = authClient.organization.checkRolePermission({
permission: {
project: ["update"],
},
role: activeMember.role,
});
```
We leverage the existing custom hook to retrieve the active member role within the [active organization](/docs/web/organizations/active-organization) context. That way, you can easily check whether a member has permission to perform an action without a server round trip.
<Callout type="warn">
This does not include any dynamic roles or permissions because everything runs synchronously on the client-side. Use the `hasPermission` APIs to include checks for dynamic roles and permissions.
</Callout>
If you need to add more granular permissions to existing roles, or create new ones, use the [`createAccessControl`](https://www.better-auth.com/docs/plugins/organization#custom-permissions) API.
For further customization - such as dynamic access control, lifecycle hooks, or team management - see the guidance in the [official documentation](https://www.better-auth.com/docs/plugins/organization).