Global WatchGlobal Watch Docs
Architecture

Multi-Tenancy

Multi-Tenancy

Global Watch uses subdomain-based routing for tenant isolation, providing a clean separation between personal accounts, team accounts, and marketing pages.

URL Structure

The platform uses different subdomains to route users to the appropriate context:

TypeSubdomainURLRoute Group
Marketing(none)global.watch(marketing)/
Personalappapp.global.watchhome/
Team{slug}acme.global.watchaccount/

Examples

# Marketing site (public)
https://global.watch
https://global.watch/pricing
https://global.watch/blog

# Personal account (authenticated)
https://app.global.watch/home
https://app.global.watch/home/settings
https://app.global.watch/home/billing

# Team account (authenticated + team member)
https://acme.global.watch/account
https://acme.global.watch/account/projects
https://acme.global.watch/account/members

Local Development

For local development, use localhost.direct which resolves to 127.0.0.1:

# Marketing
http://localhost.direct:3000

# Personal account
http://app.localhost.direct:3000

# Team account
http://acme.localhost.direct:3000

localhost.direct is a special domain that resolves to 127.0.0.1, enabling subdomain testing without modifying your hosts file.

Tenant Utilities

Global Watch provides utility functions for working with tenants:

Extracting Subdomain

import { getSubdomain } from '~/lib/tenant/tenant-utils';

// Extract subdomain from host
getSubdomain('acme.localhost.direct:3000');
// → 'acme'

getSubdomain('app.localhost.direct:3000');
// → 'app'

getSubdomain('localhost.direct:3000');
// → null

Detecting Tenant Type

import { getCurrentTenant } from '~/lib/tenant/tenant-utils';

// Personal account
getCurrentTenant('app.localhost.direct:3000');
// → { type: 'personal' }

// Team account
getCurrentTenant('acme.localhost.direct:3000');
// → { type: 'team', slug: 'acme' }

// Marketing (no subdomain)
getCurrentTenant('localhost.direct:3000');
// → { type: 'marketing' }

Building URLs

import { buildPersonalUrl, buildTeamUrl } from '~/lib/tenant/tenant-utils';

// Build personal account URL
buildPersonalUrl('/projects');
// → 'http://app.localhost.direct:3000/projects'

// Build team account URL
buildTeamUrl('acme', '/projects');
// → 'http://acme.localhost.direct:3000/projects'

Validating Team Slugs

import { isValidTeamSlug } from '~/lib/tenant/tenant-utils';

isValidTeamSlug('acme');     // → true
isValidTeamSlug('my-team');  // → true
isValidTeamSlug('app');      // → false (reserved)
isValidTeamSlug('AB');       // → false (too short)
isValidTeamSlug('admin');    // → false (reserved)

Reserved Slugs

Certain slugs are reserved and cannot be used for team accounts:

const reservedSlugs = [
  'app',        // Personal account subdomain
  'www',        // Common web prefix
  'api',        // API subdomain
  'admin',      // Admin panel
  'auth',       // Authentication
  'cdn',        // Content delivery
  'assets',     // Static assets
  'static',     // Static files
  'docs',       // Documentation
  'blog',       // Blog
  'help',       // Help center
  'support',    // Support
  'status',     // Status page
  'mail',       // Email
  'workspace',  // Generic workspace
  'map',        // Map application
  'maps',       // Maps
  'report',     // Reports
  'reports',    // Reports
];

Workspace Contexts

Global Watch provides React contexts for accessing workspace data in components.

Personal Account Context

For pages in the personal account context (/home/*):

import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';

function PersonalPage() {
  const { user, account } = useUserWorkspace();
  
  return (
    <div>
      <h1>Welcome, {user.displayName}</h1>
      <p>Account: {account.name}</p>
    </div>
  );
}

Available properties:

PropertyTypeDescription
userUserCurrent authenticated user
accountAccountPersonal account data

Team Account Context

For pages in the team account context (/account/*):

import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';

function TeamPage() {
  const { account, user, accounts } = useTeamAccountWorkspace();
  
  return (
    <div>
      <h1>{account.name}</h1>
      <p>Your role: {user.role}</p>
      <p>Team slug: {account.slug}</p>
    </div>
  );
}

Available properties:

PropertyTypeDescription
accountTeamAccountCurrent team account
userTeamMemberCurrent user's membership
accountsTeamAccount[]All teams user belongs to

Environment Variables

Configure multi-tenancy with these environment variables:

# Base domain for subdomain routing
NEXT_PUBLIC_BASE_DOMAIN=localhost.direct:3000

# Personal account URL
NEXT_PUBLIC_APP_URL=http://app.localhost.direct:3000

# Marketing site URL
NEXT_PUBLIC_SITE_URL=http://localhost.direct:3000

# Enable subdomain routing
NEXT_PUBLIC_ENABLE_SUBDOMAIN_ROUTING=true

Production Configuration

NEXT_PUBLIC_BASE_DOMAIN=global.watch
NEXT_PUBLIC_APP_URL=https://app.global.watch
NEXT_PUBLIC_SITE_URL=https://global.watch
NEXT_PUBLIC_ENABLE_SUBDOMAIN_ROUTING=true

RLS Isolation

Data isolation is enforced at the database level using Row Level Security (RLS):

-- Users can only view projects they have access to
CREATE POLICY "users_can_view_own_projects" ON projects
  FOR SELECT USING (
    account_id IN (
      SELECT account_id FROM memberships 
      WHERE user_id = auth.uid()
    )
  );

Key Principles

  1. Always use account_id - All tenant-scoped data must have an account_id foreign key
  2. RLS on all tables - Enable RLS on every table containing tenant data
  3. Membership checks - Use has_role_on_account() for team access
  4. Personal account checks - Use account_id = auth.uid() for personal data

Example RLS Policies

-- Personal account data (only owner can access)
CREATE POLICY "personal_data_access" ON personal_settings
  FOR ALL USING (
    account_id = (SELECT auth.uid())
  );

-- Team account data (any team member can access)
CREATE POLICY "team_data_access" ON team_projects
  FOR SELECT USING (
    public.has_role_on_account(account_id)
  );

-- Team data with role requirement
CREATE POLICY "team_admin_access" ON team_settings
  FOR ALL USING (
    public.has_role_on_account(account_id, 'owner')
  );

Route Organization

The Next.js App Router structure reflects the multi-tenant architecture:

apps/web/app/
├── (marketing)/          # Public marketing pages
│   ├── page.tsx          # Landing page
│   ├── pricing/
│   ├── blog/
│   └── contact/

├── home/                 # Personal account routes
│   ├── (user)/           # User workspace
│   │   ├── page.tsx      # Dashboard
│   │   ├── settings/
│   │   ├── billing/
│   │   └── teams/
│   └── layout.tsx

├── account/              # Team account routes
│   ├── page.tsx          # Team dashboard
│   ├── projects/
│   ├── members/
│   ├── settings/
│   └── layout.tsx

└── admin/                # Super admin routes
    └── ...

Middleware Configuration

The middleware handles subdomain routing:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const host = request.headers.get('host') || '';
  const subdomain = getSubdomain(host);
  
  // Route based on subdomain
  if (subdomain === 'app') {
    // Personal account - route to /home
    return NextResponse.rewrite(new URL('/home' + pathname, request.url));
  }
  
  if (subdomain && subdomain !== 'www') {
    // Team account - route to /account
    return NextResponse.rewrite(new URL('/account' + pathname, request.url));
  }
  
  // Marketing site - no rewrite needed
  return NextResponse.next();
}

Best Practices

1. Always Check Tenant Context

// ✅ CORRECT - Check context before operations
const { account } = useTeamAccountWorkspace();
await createProject({ accountId: account.id, ...data });

// ❌ WRONG - Hardcoded or missing account
await createProject({ accountId: 'some-id', ...data });

2. Use Workspace Hooks

// ✅ CORRECT - Use provided hooks
const { account } = useTeamAccountWorkspace();

// ❌ WRONG - Parse URL manually
const slug = window.location.hostname.split('.')[0];

3. Validate Slugs on Creation

// ✅ CORRECT - Validate before creating team
if (!isValidTeamSlug(slug)) {
  return { error: 'Invalid team slug' };
}
await createTeam({ slug, name });

4. Handle Cross-Tenant Navigation

// ✅ CORRECT - Use URL builders for navigation
const teamUrl = buildTeamUrl(team.slug, '/projects');
router.push(teamUrl);

// ❌ WRONG - Construct URLs manually
router.push(`https://${team.slug}.global.watch/projects`);

Next Steps

On this page