Optare v1.0 is now available. Get started →
Learn
Organizations

Organizations

Organizations are the foundation of multi-tenant applications in Optare. They provide complete data isolation and role-based access control for SaaS products.

Overview

An organization represents a group of users working together. Each organization has:

  • Members - Users with specific roles
  • Roles - Owner, Admin, or Member
  • Resources - OAuth clients, API keys, webhooks
  • Subscriptions - Product licenses with seat limits
  • Settings - Custom configuration and branding

Creating Organizations

Automatic Creation on Signup

Organizations can be created automatically when a user signs up:

import { auth } from "~/lib/auth";
 
// In your signup handler
const { user, session } = await auth.api.signUp({
  email: "founder@startup.com",
  password: "secure-password",
  name: "Jane Doe",
});
 
// Create organization for the user
const organization = await auth.api.organization.create({
  name: "Startup Inc",
  slug: "startup-inc",
  metadata: {
    industry: "Technology",
    size: "1-10",
  },
  headers: request.headers,
});
 
// Add user as owner
await auth.api.organization.addMember({
  organizationId: organization.id,
  userId: user.id,
  role: "owner",
  headers: request.headers,
});

Manual Organization Creation

Users can create organizations from your UI:

"use client";
 
import { authClient } from "~/lib/auth-client";
import { useState } from "react";
 
export function CreateOrganizationForm() {
  const [name, setName] = useState("");
  const [slug, setSlug] = useState("");
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
 
    const { data, error } = await authClient.organization.create({
      name,
      slug,
    });
 
    if (error) {
      console.error("Failed to create organization:", error);
      return;
    }
 
    console.log("Organization created:", data);
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Organization Name"
        value={name}
        onChange={(e) => setName(e.target.value)}
        required
      />
      <input
        type="text"
        placeholder="slug"
        value={slug}
        onChange={(e) => setSlug(e.target.value)}
        required
      />
      <button type="submit">Create Organization</button>
    </form>
  );
}

Organization Roles

Optare supports three built-in roles:

Owner

  • Full control over the organization
  • Can delete the organization
  • Can manage all members and roles
  • Can access billing and subscriptions
  • Limit: One owner per organization

Admin

  • Can manage members (except owner)
  • Can create and manage resources
  • Can view audit logs
  • Cannot delete organization
  • Cannot change owner

Member

  • Read-only access to organization
  • Can use licensed products
  • Cannot manage other members
  • Cannot create resources

Role Comparison

PermissionOwnerAdminMember
Delete organization
Manage owner
Manage admins
Manage members
Create OAuth clients
Create API keys
Create webhooks
View audit logs
Use products

Managing Members

Inviting Members

Send email invitations to new members:

import { auth } from "~/lib/auth";
 
export async function inviteMember(
  organizationId: string,
  email: string,
  role: "admin" | "member"
) {
  const invitation = await auth.api.createInvitation({
    body: {
      email,
      role,
      organizationId,
      resend: false, // Set to true to resend
    },
    headers: request.headers,
  });
 
  // Invitation email is sent automatically
  return invitation;
}

The invitation email contains a link like:

https://yourapp.com/invites/{invitationId}

Accepting Invitations

Create an invitation acceptance page:

// app/routes/invites.$token.tsx
import { auth } from "~/lib/auth";
import { redirect } from "@remix-run/node";
 
export async function loader({ params, request }: LoaderFunctionArgs) {
  const { token } = params;
  
  // Get invitation details
  const invitation = await auth.api.getInvitation({
    invitationId: token,
  });
 
  if (!invitation || invitation.status !== "pending") {
    throw new Response("Invalid or expired invitation", { status: 404 });
  }
 
  return json({ invitation });
}
 
export async function action({ params, request }: ActionFunctionArgs) {
  const { token } = params;
 
  // Accept the invitation
  await auth.api.acceptInvitation({
    invitationId: token,
    headers: request.headers,
  });
 
  return redirect("/dashboard");
}

Listing Members

Get all members of an organization:

import { db } from "~/lib/db";
import { organizationMembers, users } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function getOrganizationMembers(organizationId: string) {
  const members = await db
    .select({
      id: organizationMembers.id,
      role: organizationMembers.role,
      status: organizationMembers.status,
      joinedAt: organizationMembers.joinedAt,
      user: {
        id: users.id,
        name: users.name,
        email: users.email,
        image: users.image,
      },
    })
    .from(organizationMembers)
    .innerJoin(users, eq(organizationMembers.userId, users.id))
    .where(eq(organizationMembers.organizationId, organizationId))
    .orderBy(organizationMembers.joinedAt);
 
  return members;
}

Removing Members

Remove a member from an organization:

import { auth } from "~/lib/auth";
 
export async function removeMember(
  organizationId: string,
  memberId: string
) {
  await auth.api.organization.removeMember({
    organizationId,
    memberId,
    headers: request.headers,
  });
}

Updating Member Roles

Change a member's role:

import { db } from "~/lib/db";
import { organizationMembers } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function updateMemberRole(
  memberId: string,
  newRole: "owner" | "admin" | "member"
) {
  await db
    .update(organizationMembers)
    .set({ role: newRole })
    .where(eq(organizationMembers.id, memberId));
}

Organization Context

Getting Current Organization

Retrieve the user's current organization from session:

import { auth } from "~/lib/auth";
 
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });
 
  if (!session) {
    throw redirect("/login");
  }
 
  // Get user's organization membership
  const membership = await db.query.organizationMembers.findFirst({
    where: eq(organizationMembers.userId, session.user.id),
    with: {
      organization: true,
    },
  });
 
  if (!membership) {
    throw redirect("/onboarding");
  }
 
  return json({
    user: session.user,
    organization: membership.organization,
    role: membership.role,
  });
}

Client-Side Organization Access

Access organization data in React components:

"use client";
 
import { useSession } from "~/lib/auth-client";
 
export function OrganizationHeader() {
  const { data: session } = useSession();
 
  if (!session?.organizationId) {
    return null;
  }
 
  return (
    <div>
      <h2>{session.organization.name}</h2>
      <p>Role: {session.role}</p>
    </div>
  );
}

Domain Verification

Enable automatic organization joining for verified email domains:

Adding a Domain

import { db } from "~/lib/db";
import { organizations } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function addDomain(
  organizationId: string,
  domain: string
) {
  const org = await db.query.organizations.findFirst({
    where: eq(organizations.id, organizationId),
  });
 
  const domains = org.domains || [];
  domains.push({
    domain,
    status: "pending",
    verificationToken: crypto.randomUUID(),
  });
 
  await db
    .update(organizations)
    .set({ domains })
    .where(eq(organizations.id, organizationId));
}

Verifying a Domain

Users add a TXT record to their DNS:

TXT record: optare-verification={verificationToken}

Then verify:

import { Resolver } from "dns/promises";
 
export async function verifyDomain(
  organizationId: string,
  domain: string
) {
  const org = await db.query.organizations.findFirst({
    where: eq(organizations.id, organizationId),
  });
 
  const domainConfig = org.domains.find((d) => d.domain === domain);
  if (!domainConfig) {
    throw new Error("Domain not found");
  }
 
  // Check DNS TXT record
  const resolver = new Resolver();
  const txtRecords = await resolver.resolveTxt(domain);
  
  const verified = txtRecords.some((record) =>
    record.includes(`optare-verification=${domainConfig.verificationToken}`)
  );
 
  if (verified) {
    // Update domain status
    const updatedDomains = org.domains.map((d) =>
      d.domain === domain ? { ...d, status: "verified" } : d
    );
 
    await db
      .update(organizations)
      .set({ domains: updatedDomains })
      .where(eq(organizations.id, organizationId));
  }
 
  return verified;
}

Auto-Join on Signup

When a user signs up with a verified domain email:

// In auth configuration (libs/auth/src/index.ts)
events: {
  async onSignUp({ user }) {
    const emailDomain = user.email.split('@')[1];
    if (!emailDomain) return;
 
    // Find organization with verified domain
    const org = await db.query.organizations.findFirst({
      where: sql`domains @> '[{"domain": ${emailDomain}, "status": "verified"}]'`,
    });
 
    if (org) {
      // Automatically add user as member
      await db.insert(organizationMembers).values({
        id: crypto.randomUUID(),
        organizationId: org.id,
        userId: user.id,
        role: 'member',
        status: 'active',
        joinedAt: new Date(),
      });
    }
  }
}

Organization Switching

Allow users to switch between multiple organizations:

"use client";
 
import { useState } from "react";
import { authClient } from "~/lib/auth-client";
 
export function OrganizationSwitcher({ organizations }) {
  const [currentOrg, setCurrentOrg] = useState(organizations[0]);
 
  const switchOrganization = async (orgId: string) => {
    // Update session with new organization
    await authClient.organization.setActive({
      organizationId: orgId,
    });
 
    // Reload to update UI
    window.location.reload();
  };
 
  return (
    <select
      value={currentOrg.id}
      onChange={(e) => switchOrganization(e.target.value)}
    >
      {organizations.map((org) => (
        <option key={org.id} value={org.id}>
          {org.name}
        </option>
      ))}
    </select>
  );
}

Best Practices

1. Always Check Organization Access

export async function loader({ request, params }: LoaderFunctionArgs) {
  const session = await auth.api.getSession({ headers: request.headers });
  
  const membership = await db.query.organizationMembers.findFirst({
    where: and(
      eq(organizationMembers.userId, session.user.id),
      eq(organizationMembers.organizationId, params.orgId)
    ),
  });
 
  if (!membership) {
    throw new Response("Forbidden", { status: 403 });
  }
 
  // User has access, continue...
}

2. Enforce Role Permissions

function requireRole(membership, allowedRoles: string[]) {
  if (!allowedRoles.includes(membership.role)) {
    throw new Response("Insufficient permissions", { status: 403 });
  }
}
 
// Usage
requireRole(membership, ["owner", "admin"]);

3. Use Organization Scoping

Always scope queries to the current organization:

// ✅ Good
const clients = await db
  .select()
  .from(oauthClients)
  .where(eq(oauthClients.organizationId, membership.organizationId));
 
// ❌ Bad - exposes all organizations
const clients = await db.select().from(oauthClients);

4. Handle Seat Limits

Check available seats before inviting:

export async function checkAvailableSeats(organizationId: string) {
  const subscription = await db.query.organizationSubscriptions.findFirst({
    where: eq(organizationSubscriptions.organizationId, organizationId),
  });
 
  if (!subscription) {
    throw new Error("No active subscription");
  }
 
  const availableSeats = subscription.totalSeats - subscription.seatsUsed;
  
  if (availableSeats <= 0) {
    throw new Error("No available seats. Please upgrade your plan.");
  }
 
  return availableSeats;
}

Edge Cases

User Leaves Last Organization

// Redirect to onboarding to create new organization
if (userOrganizations.length === 0) {
  return redirect("/onboarding");
}

Deleting Organization with Active Members

export async function deleteOrganization(organizationId: string) {
  // Check if user is owner
  if (membership.role !== "owner") {
    throw new Error("Only owners can delete organizations");
  }
 
  // Remove all members first
  await db
    .delete(organizationMembers)
    .where(eq(organizationMembers.organizationId, organizationId));
 
  // Delete organization (cascades to resources)
  await db
    .delete(organizations)
    .where(eq(organizations.id, organizationId));
}

Invitation Expiration

// Check if invitation is expired
const isExpired = new Date() > new Date(invitation.expiresAt);
 
if (isExpired) {
  throw new Error("This invitation has expired");
}