Skip to content

Organizations

The @cruzjs/start package provides a complete multi-tenant organization system. Organizations are the primary unit of tenancy — users belong to one or more organizations, and data is scoped to the organization level.

The organization system includes:

  • OrgService — CRUD operations for organizations
  • Slug-based URLs — Human-readable org identifiers (/orgs/acme-corp/dashboard)
  • Soft deletes — Organizations are never hard-deleted
  • Settings storage — JSON settings per organization
  • Org switching — Users can switch between their organizations

Organizations are stored in the Organization table:

import { DrizzleUniversalFactory } from '@cruzjs/drizzle-universal';
const f = DrizzleUniversalFactory.create((b) => ({
organizations: b.table('Organization', {
id: b.text('id').primaryKey().$defaultFn(generateId),
name: b.text('name').notNull(),
slug: b.text('slug').notNull().unique(),
ownerId: b.text('ownerId').notNull().references(() => authIdentity.id),
avatarUrl: b.text('avatarUrl'),
stripeCustomerId: b.text('stripeCustomerId'),
settings: b.json('settings').default('{}'),
deletedAt: b.timestamp('deletedAt'), // Soft delete timestamp
createdAt: b.timestamp('createdAt').notNull().$defaultFn(nowISO),
updatedAt: b.timestamp('updatedAt').notNull().$defaultFn(nowISO),
}),
}));
export const { organizations } = f();

The OrgService is an injectable service for organization CRUD:

import { OrgService } from '@cruzjs/start/orgs/org.service';
// In a tRPC router or service
const org = await orgService.createOrg(
{
name: 'Acme Corporation',
slug: 'acme-corp', // Optional -- auto-generated from name
avatarUrl: 'https://...', // Optional
settings: { // Optional JSON settings
timezone: 'America/New_York',
defaultCurrency: 'USD',
},
},
userId // Creator becomes OWNER
);

When creating an organization:

  1. A slug is generated from the name if not provided
  2. Slug uniqueness is enforced (appends a suffix like -2 if needed)
  3. The creator is automatically added as an OWNER member
// Get by ID
const org = await orgService.getOrg(orgId);
// Get by slug (for URL resolution)
const org = await orgService.getOrgBySlug('acme-corp');
// List all organizations for a user
const orgs = await orgService.listUserOrgs(userId);
// Get organization with member count
const orgWithStats = await orgService.getOrgWithStats(orgId);
// { ...org, memberCount: 5 }
const updated = await orgService.updateOrg(orgId, {
name: 'Acme Corp International',
slug: 'acme-intl',
avatarUrl: 'https://new-avatar.jpg',
settings: { timezone: 'Europe/London' },
});

Organizations are soft-deleted by setting the deletedAt timestamp. They are excluded from all queries by default:

await orgService.deleteOrg(orgId);
// Sets deletedAt, does not remove the row

CruzJS uses slug-based routing for organization-scoped pages:

/orgs/:slug/dashboard
/orgs/:slug/settings
/orgs/:slug/members
/orgs/:slug/billing

The org context middleware resolves the slug to an organization ID and verifies the user’s membership:

// In a React Router loader
export async function loader({ request, params }: LoaderFunctionArgs) {
const auth = await requireSession(request);
const orgContext = await requireOrgContext(request, params, auth);
// orgContext.org.orgId -- resolved organization ID
// orgContext.org.role -- user's role (OWNER, ADMIN, MEMBER, VIEWER)
return { org: await orgService.getOrg(orgContext.org.orgId) };
}

Slugs are generated from organization names using these rules:

  • Lowercase all characters
  • Replace spaces and special characters with hyphens
  • Remove consecutive hyphens
  • Trim hyphens from start and end
  • Ensure uniqueness by appending -2, -3, etc. if needed
"Acme Corporation" → "acme-corporation"
"My Band!!" → "my-band"
"Acme Corp" (dup) → "acme-corp-2"

Users can belong to multiple organizations. The current organization is stored in the session:

// The session tracks which org the user is currently viewing
type SessionData = {
userId: string;
currentOrgId: string | null;
expiresAt: string;
};

The client-side organization type includes the user’s role and current status:

type Organization = {
id: string;
name: string;
slug: string;
avatarUrl: string | null;
role: 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER';
isCurrent: boolean;
};
// tRPC mutation to switch organizations
const switchOrg = trpc.org.switchOrg.useMutation();
function OrgSwitcher({ orgs }: { orgs: Organization[] }) {
return (
<select
onChange={(e) => switchOrg.mutate({ orgId: e.target.value })}
>
{orgs.map((org) => (
<option key={org.id} value={org.id} selected={org.isCurrent}>
{org.name}
</option>
))}
</select>
);
}

Settings are stored as JSON in the settings column. Use them for org-level configuration:

// Read settings
const org = await orgService.getOrg(orgId);
const timezone = org?.settings?.timezone as string ?? 'UTC';
// Update settings
await orgService.updateOrg(orgId, {
settings: {
...existingSettings,
notificationsEnabled: true,
maxUploadSizeMB: 50,
},
});

Organization operations are available through tRPC:

import { orgProcedure, router } from '@cruzjs/core/trpc/context';
export const orgRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const orgService = ctx.container.get(OrgService);
return orgService.listUserOrgs(ctx.session.user.id);
}),
create: protectedProcedure
.input(createOrgSchema)
.mutation(async ({ ctx, input }) => {
const orgService = ctx.container.get(OrgService);
return orgService.createOrg(input, ctx.session.user.id);
}),
update: orgProcedure
.input(updateOrgSchema)
.mutation(async ({ ctx, input }) => {
const orgService = ctx.container.get(OrgService);
return orgService.updateOrg(ctx.org.orgId, input);
}),
});