A production-ready multi-tenant workspace management system for Next.js 14 SaaS applications. Includes workspace CRUD, role-based access control (RBAC), member management, invitations, and a sleek dashboard UI.
Stack: Next.js 14 Β· Supabase Β· Resend (optional, for email invites)
- β Create workspaces with auto-generated slugs and unique names
- β Update workspace settings (name, logo, custom settings JSON)
- β Delete workspaces with safety checks (owner-only)
- β Retrieve workspaces by ID or slug
- β List user workspaces with role information
- β Member CRUD: Add, remove, list workspace members
- β Role management: Assign/update roles (owner β admin β member β viewer)
- β Ownership transfer: Securely transfer workspace ownership
- β Member view: Join profiles with email, name, and avatar data
- β Send invites to any email with configurable roles
- β Unique tokens for invite links (auto-generated UUIDs)
- β 7-day expiry with customizable duration
- β Accept invites with automatic member creation
- β Revoke pending invites
- β List pending invites per workspace
- β Re-invite handling: Sending to same email generates new token
- β Row-Level Security (RLS) on all tables
- β Role-based permissions with granular action checks
- β Owner-only operations: Billing, deletion, ownership transfer
- β Admin operations: Member management, invitations, settings
- β Viewer-only access: Read-only workspace viewing
- β Sleek dark theme with Tailwind CSS
- β Member list with avatars, roles, status
- β Invite form with role selection
- β Pending invites section with revoke controls
- β Quick stats panel with workspace info
- β Admin controls for billing, deletion, data export
- β Responsive design (mobile-friendly)
Primary workspace records with ownership and billing info.
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
owner_id UUID NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free','pro','enterprise')),
logo_url TEXT,
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);RLS Policies:
ws_member_select: Members can view workspaces they're part ofws_owner_all: Owners have full CRUD access
Maps users to workspaces with roles.
CREATE TABLE workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('owner','admin','member','viewer')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (workspace_id, user_id)
);RLS Policies:
wm_member_select: Members see workspace memberswm_admin_manage: Admins/owners manage members
Pending invitations with unique tokens and expiry.
CREATE TABLE workspace_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin','member','viewer')),
token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
invited_by UUID REFERENCES profiles(id),
accepted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (workspace_id, email)
);RLS Policies:
wi_member_select: Only admins/owners see invites
Joins members with profile data for convenient querying.
CREATE OR REPLACE VIEW workspace_members_view AS
SELECT
wm.id, wm.workspace_id, wm.role, wm.joined_at,
p.id AS user_id, p.email, p.name, p.avatar_url
FROM workspace_members wm
JOIN profiles p ON p.id = wm.user_id;viewer (0) < member (1) < admin (2) < owner (3)
| Action | Viewer | Member | Admin | Owner |
|---|---|---|---|---|
workspace:view |
β | β | β | β |
member:view |
β | β | β | β |
workspace:invite |
β | β | β | β |
member:remove |
β | β | β | β |
member:change_role |
β | β | β | β |
workspace:settings |
β | β | β | β |
workspace:billing |
β | β | β | β |
workspace:delete |
β | β | β | β |
npm install @supabase/supabase-js resend# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
RESEND_API_KEY=your-resend-key-optionalCopy the SQL schema from the comments in lib/workspaces.ts and execute in your Supabase SQL editor. This creates all tables, RLS policies, and views.
import {
createWorkspace,
inviteMember,
getWorkspaceMembers,
updateMemberRole
} from '@/lib/workspaces'
// Create workspace
const ws = await createWorkspace(userId, 'My Team', 'free')
// Invite member
const invite = await inviteMember(ws.id, userId, 'john@company.com', 'admin')
// Get members
const members = await getWorkspaceMembers(ws.id)import { useWorkspace } from '@/lib/workspaces'
export default function Dashboard({ wsId }) {
const { workspace, members, userRole, canDo, refresh } = useWorkspace(wsId)
if (canDo('workspace:invite')) {
// Show invite form
}
return (
// Your component
)
}Creates a new workspace with auto-generated slug.
const ws = await createWorkspace(
'user-123',
'Engineering Team',
'pro'
)
// Returns: WorkspaceParameters:
ownerId(string, required): User ID of workspace ownername(string, required): Workspace nameplan(WorkspacePlan, optional): 'free' | 'pro' | 'enterprise', default: 'free'
Returns: Workspace object
Fetch a single workspace by ID or slug.
const ws = await getWorkspace('my-team-slug')Parameters:
idOrSlug(string): Workspace UUID or slug
Returns: Workspace | null
Get all workspaces a user belongs to.
const workspaces = await getUserWorkspaces('user-123')
// Returns: Workspace[] with userRole attachedParameters:
userId(string): User UUID
Returns: Workspace[] (with userRole field)
Update workspace properties.
await updateWorkspace('ws-123', {
name: 'New Name',
logo_url: 'https://...',
settings: { theme: 'dark' }
})Parameters:
wsId(string): Workspace UUIDupdates(object):name?(string): New workspace namelogo_url?(string | null): Logo image URLsettings?(Record<string, any>): Custom settings JSON
Returns: Promise (void)
Delete a workspace (owner-only).
await deleteWorkspace('ws-123', 'user-123')Parameters:
wsId(string): Workspace UUIDownerId(string): User UUID (must be workspace owner)
Returns: Promise (void)
List all members in a workspace with profile data.
const members = await getWorkspaceMembers('ws-123')
// Returns: WorkspaceMember[] with email, name, avatarParameters:
wsId(string): Workspace UUID
Returns: WorkspaceMember[]
Get a member's role in a workspace.
const role = await getMemberRole('ws-123', 'user-456')
// Returns: 'owner' | 'admin' | 'member' | 'viewer' | nullParameters:
wsId(string): Workspace UUIDuserId(string): User UUID
Returns: WorkspaceRole | null
Change a member's role (cannot assign 'owner').
await updateMemberRole('ws-123', 'user-456', 'admin')Parameters:
wsId(string): Workspace UUIDuserId(string): User UUIDnewRole(WorkspaceRole): 'admin' | 'member' | 'viewer'
Returns: Promise (void)
Throws: Error if trying to assign 'owner' role
Remove a member from a workspace.
await removeMember('ws-123', 'user-456')Parameters:
wsId(string): Workspace UUIDuserId(string): User UUID
Returns: Promise (void)
Transfer workspace ownership (current owner becomes admin).
await transferOwnership('ws-123', 'old-owner', 'new-owner')Parameters:
wsId(string): Workspace UUIDcurrentOwnerId(string): Current owner user IDnewOwnerId(string): New owner user ID
Returns: Promise (void)
Send an invitation to a new or existing user.
const invite = await inviteMember(
'ws-123',
'user-456',
'john@company.com',
'member'
)Parameters:
wsId(string): Workspace UUIDinviterId(string): User UUID of inviteremail(string): Email to invite (lowercased)role(WorkspaceRole, optional): 'admin' | 'member' | 'viewer', default: 'member'
Returns: WorkspaceInvite (includes token)
Throws: Error if email is already a member
Validate and retrieve an invite by token (must be unexpired and unaccepted).
const invite = await getInviteByToken('token-123')Parameters:
token(string): Invite token from email link
Returns: WorkspaceInvite | null
Accept an invitation and add user to workspace.
await acceptInvite('token-123', 'user-456')Parameters:
token(string): Invite tokenuserId(string): User UUID accepting the invite
Returns: Promise (void)
Throws: Error if invite is invalid or expired
Delete a pending invitation.
await revokeInvite('invite-123')Parameters:
inviteId(string): Invite UUID
Returns: Promise (void)
List all unexpired, unaccepted invites for a workspace.
const invites = await getPendingInvites('ws-123')Parameters:
wsId(string): Workspace UUID
Returns: WorkspaceInvite[]
Check if a user role meets a minimum permission level.
const canManage = hasWorkspacePermission('admin', 'member')
// true β admin >= member in hierarchyParameters:
userRole(WorkspaceRole): User's current rolerequired(WorkspaceRole): Required minimum role
Returns: boolean
Check if a user can perform a specific action.
const canInvite = canDo('admin', 'workspace:invite')
// trueParameters:
userRole(WorkspaceRole): User's current roleaction(string): Action key (see Permission Matrix)
Returns: boolean
Client-side hook for loading and managing workspace data.
const {
workspace, // Workspace | null
members, // WorkspaceMember[]
invites, // WorkspaceInvite[]
userRole, // WorkspaceRole | null
loading, // boolean
error, // string | null
refresh, // () => Promise<void>
canDo // (action: string) => boolean
} = useWorkspace('ws-123')Returns:
workspace: Current workspace datamembers: List of membersinvites: Pending invitesuserRole: User's role in this workspaceloading: Data fetch stateerror: Any fetch errorsrefresh: Manual data refresh functioncanDo: Permission checker bound to userRole
Pre-built dashboard component for workspace management.
import WorkspaceDashboard from '@/components/workspaces/WorkspaceDashboard'
export default function Page({ params }) {
return <WorkspaceDashboard params={{ wsId: params.id }} />
}- Member list with role badges, avatars, status
- Inline role editor (hover to reveal)
- Remove member button with confirmation
- Invite form with email and role selection
- Pending invites section with revoke controls
- Workspace info panel: Slug, plan, creation date
- Admin controls: Transfer ownership, delete, export
- Responsive layout: 2-column on desktop, single on mobile
- Dark theme with glassmorphism effects
interface WorkspaceDashboardProps {
params: {
wsId: string // Workspace UUID
}
}All operations are protected by Supabase RLS policies:
- Users can only see workspaces they're members of
- Only admins/owners can view and manage members
- Only workspace owners can delete workspaces
The service role key bypasses RLS. Only use it in server-side contexts (API routes, server actions). Never expose it to the client.
Always check permissions before showing UI or processing actions:
if (!canDo(userRole, 'workspace:invite')) {
throw new Error('Unauthorized')
}Slugs are automatically generated and enforced as UNIQUE at the database level. The uniqueSlug() function handles collisions.
Tokens are auto-generated UUIDs and stored in the database. Implement email verification for production:
// Use Resend or similar to send invite email
await resend.emails.send({
from: 'noreply@company.com',
to: email,
subject: `Join ${workspaceName}`,
html: `<a href="https://app.com/join?token=${token}">Accept invite</a>`
})export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer'
export type WorkspacePlan = 'free' | 'pro' | 'enterprise'
export interface Workspace {
id: string
name: string
slug: string
owner_id: string
plan: WorkspacePlan
logo_url: string | null
settings: Record<string, any>
created_at: string
member_count?: number
}
export interface WorkspaceMember {
id: string
workspace_id: string
user_id: string
role: WorkspaceRole
joined_at: string
email: string
name: string | null
avatar_url: string | null
}
export interface WorkspaceInvite {
id: string
workspace_id: string
email: string
role: WorkspaceRole
token: string
invited_by: string | null
accepted_at: string | null
expires_at: string
created_at: string
}const ws = await createWorkspace(userId, 'My Team')
await Promise.all([
inviteMember(ws.id, userId, 'alice@company.com', 'admin'),
inviteMember(ws.id, userId, 'bob@company.com', 'member'),
inviteMember(ws.id, userId, 'charlie@company.com', 'viewer')
])const role = await getMemberRole(wsId, userId)
if (!canDo(role, 'workspace:invite')) {
throw new Error('Not authorized')
}
// Proceed with actionconst workspaces = await getUserWorkspaces(userId)
const withMemberCounts = await Promise.all(
workspaces.map(async (ws) => ({
...ws,
member_count: (await getWorkspaceMembers(ws.id)).length
}))
)const invite = await getInviteByToken(token)
if (!invite) {
throw new Error('Invalid or expired invite')
}
const { data: { session } } = await supabase.auth.getSession()
await acceptInvite(token, session.user.id)Ensure you're passing the correct ownerId that matches the workspace's owner_id.
Use transferOwnership() instead of updateMemberRole() for owner transfers.
Invites expire after 7 days. Check expires_at or re-send the invite.
Ensure the authenticated user is a member of the workspace. Check the Supabase RLS policies match the schema comments.
You can only have one pending invite per email per workspace. Upsert a new invite to refresh the token.
lib/
βββ workspaces.ts # All backend functions & hooks
βββ components/
βββ workspaces/
βββ WorkspaceDashboard.tsx # Dashboard UI component
Before deploying to production:
- SQL migrations applied to Supabase
- RLS policies enabled on all tables
-
NEXT_PUBLIC_SUPABASE_URLset -
NEXT_PUBLIC_SUPABASE_ANON_KEYset -
SUPABASE_SERVICE_ROLE_KEYset (server-side only) -
RESEND_API_KEYconfigured (if using email invites) - Invite email templates created
- Ownership transfer UI tested
- Member removal confirmations implemented
- Role permissions audit completed
Part of the MarrowStack framework. Use freely in your projects.
Found a bug or want to improve this block? Contributions welcome!
For questions or issues, refer to the Supabase documentation or file an issue.