High-Level Architecture

Obeya Cloud follows a modular monolith architecture deployed as a set of containerized services:
┌─────────────────────────────────────────────────────────┐
│                        CDN / Edge                        │
│                  (Vercel / Cloudflare)                    │
└─────────────┬───────────────────────────┬───────────────┘
              │                           │
    ┌─────────▼─────────┐     ┌──────────▼──────────┐
    │   Next.js App      │     │   WebSocket Server   │
    │   (App Router)     │     │   (Hono + Bun)       │
    │   - Pages/API      │     │   - Real-time sync   │
    │   - tRPC server    │     │   - Presence         │
    │   - Auth.js        │     │   - Live cursors     │
    └────────┬───────────┘     └──────────┬──────────┘
             │                            │
    ┌────────▼────────────────────────────▼──────────┐
    │                Shared Packages                  │
    │   @obeya/api  @obeya/db  @obeya/auth           │
    │   @obeya/validators  @obeya/ui                  │
    └────────┬──────────┬─────────┬─────────┬───────┘
             │          │         │         │
    ┌────────▼──┐ ┌─────▼───┐ ┌──▼────┐ ┌──▼──────┐
    │ PostgreSQL │ │  Redis  │ │ MinIO │ │Meili-   │
    │    16      │ │    7    │ │  (S3) │ │search   │
    └───────────┘ └─────────┘ └───────┘ └─────────┘

Multi-Tenancy

Obeya Cloud uses subdomain-based multi-tenancy with logical isolation at the database level:
// Middleware: Extract tenant from subdomain
export function middleware(request: NextRequest) {
  const hostname = request.headers.get("host") ?? "";
  const subdomain = hostname.split(".")[0];

  // Resolve tenant from subdomain
  const tenant = await resolveTenant(subdomain);

  // Inject tenant into request headers
  const headers = new Headers(request.headers);
  headers.set("x-tenant-id", tenant.id);

  return NextResponse.next({ request: { headers } });
}
All database queries are automatically scoped to the current tenant via Drizzle ORM middleware:
// Every query includes the tenant filter
const items = await db.query.items.findMany({
  where: and(
    eq(items.organizationId, ctx.tenant.id),
    eq(items.boardId, input.boardId),
  ),
});
Never bypass the tenant filter. All data access must go through the tRPC procedures which enforce tenant isolation automatically.

Database Schema

The core data model follows the hierarchy described in Concepts:
organizations
  └── workspaces
       └── projects
            └── boards
                 ├── groups
                 │    └── items
                 │         └── values
                 ├── columns
                 └── views
Key design decisions:
  • EAV pattern for custom fields: values table stores arbitrary data keyed by (item_id, column_id)
  • Polymorphic value storage: The values table has typed columns (text_value, number_value, date_value, json_value) to support efficient queries
  • Soft deletes: Items and boards use deleted_at timestamps for trash/recovery
  • Optimistic locking: version column on items prevents concurrent edit conflicts

tRPC API Layer

The API is built with tRPC v11, providing end-to-end type safety:
// Router definition (packages/api/src/routers/items.ts)
export const itemRouter = createTRPCRouter({
  list: protectedProcedure
    .input(z.object({
      boardId: z.string().uuid(),
      groupId: z.string().uuid().optional(),
    }))
    .query(async ({ ctx, input }) => {
      return ctx.db.query.items.findMany({
        where: and(
          eq(items.organizationId, ctx.tenant.id),
          eq(items.boardId, input.boardId),
          input.groupId
            ? eq(items.groupId, input.groupId)
            : undefined,
        ),
        orderBy: [asc(items.position)],
        with: { values: true },
      });
    }),

  create: protectedProcedure
    .input(createItemSchema)
    .mutation(async ({ ctx, input }) => {
      // ... create item logic
    }),
});

Real-Time Architecture

Real-time features run on a separate WebSocket server built with Hono on Bun:
// WebSocket connection handler
app.get("/ws/:boardId", upgradeWebSocket((c) => ({
  onOpen(event, ws) {
    const boardId = c.req.param("boardId");
    const userId = c.get("userId");
    boardManager.join(boardId, userId, ws);
  },
  onMessage(event, ws) {
    const message = JSON.parse(event.data);
    switch (message.type) {
      case "cursor_move":
        boardManager.broadcastCursor(boardId, userId, message.position);
        break;
      case "item_update":
        boardManager.broadcastUpdate(boardId, message.payload);
        break;
    }
  },
  onClose() {
    boardManager.leave(boardId, userId);
  },
})));
The WebSocket server communicates with the main application via Redis pub/sub for cross-instance message broadcasting. This enables horizontal scaling of both the Next.js app and the WebSocket server.

Caching Strategy

  • Redis caches tenant resolution, session data, and frequently accessed board metadata
  • React Query (TanStack Query) handles client-side caching with automatic invalidation via tRPC
  • CDN caches static assets and public pages at the edge

Search Architecture

Meilisearch provides fast, typo-tolerant full-text search:
  • Items are indexed in real time via database triggers
  • Each tenant has a separate Meilisearch index for data isolation
  • Search results are filtered by board-level permissions before returning

Deployment

Obeya Cloud can be deployed as:
  1. Managed SaaS — Hosted on Vercel (Next.js) + Railway (services)
  2. Self-hosted — Docker Compose for simple setups, Kubernetes for production
  3. Hybrid — Self-hosted data plane with managed control plane