FlowSpace is a unified project management platform that blends the structural power of ClickUp, the document flexibility of Notion, and the clean aesthetic of Linear — built open source from day one with a cloud SaaS layer on top.
Core belief: Teams don't fail because of missing features. They fail because tools are either too rigid (Jira) or too free-form (Notion). FlowSpace gives you structure where you need it and freedom where you want it.
Tagline: Work that flows.
| Layer | Technology | Version | Why We Use It |
|---|---|---|---|
| Framework | Next.js | 16.2 (latest stable) | The current production release. v16 ships Turbopack as the default bundler (5–10× faster Fast Refresh vs webpack), Cache Components via PPR for instant navigation, and proxy.ts replacing middleware.ts for an explicit network boundary. v16.2 adds a stable Adapter API for multi-provider deployment and Agent DevTools for AI-assisted debugging. |
| UI Components | shadcn/ui + Radix UI | Latest | shadcn/ui is not a library — it's a registry of copy-paste components built on Radix primitives. You own the code, so there's no black-box dependency to fight. Radix handles all the accessibility (ARIA, keyboard nav, focus management) so you never have to. |
| Styling | Tailwind CSS | v4 | v4 rewrites the engine in Rust (Lightning CSS), drops the config file in favour of CSS-native @theme, and is 5× faster to build. Utility-first means design tokens live in one place and every component stays consistent without custom CSS sprawl. |
| Animation | Motion | v12 | The standalone successor to Framer Motion (same API, smaller bundle). Spring physics, layout animations, and scroll-driven effects. We use it for task panel slide-overs, kanban card drags, and micro-interactions — things CSS alone can't handle smoothly. |
| Rich Text | BlockNote | v0.x latest | A Notion-style block editor built on TipTap/ProseMirror. Gives us slash commands, drag-to-reorder blocks, collaborative editing hooks, and a clean JSON document model out of the box. Writing this from scratch would cost months. |
| Drag & Drop | dnd-kit | v6 | The only production-ready DnD library that is accessible (keyboard + screen reader support), works with virtual lists, and handles the complex pointer/touch edge cases that matter in a kanban board. react-beautiful-dnd is unmaintained; dnd-kit is the successor. |
| State | Zustand | v5 | Minimal client state for UI concerns (sidebar open/closed, active view, selection state). ~1KB, no boilerplate, no context providers. We deliberately keep server state in TanStack Query and reserve Zustand for ephemeral UI state only. |
| Forms | React Hook Form + Zod | RHF v7 / Zod v3 | RHF is uncontrolled by default so re-renders only happen on blur/submit — critical for large task forms. Zod provides end-to-end type safety: the same schema validates on the frontend and is reused in the tRPC router, so a bad payload is impossible. |
| Data Fetching | TanStack Query | v5 | Manages all server state: caching, background refetching, optimistic updates, and deduplication. Optimistic mutations are how we make the UI feel instant — the task moves on the board before the server responds, and rolls back silently on error. |
| Tables | TanStack Table | v8 | Headless table logic (sorting, filtering, pagination, grouping, column visibility) with zero opinion on markup or styling. Pairs with TanStack Virtual for rendering 10,000+ task rows without a performance cliff. |
| Charts | Recharts | v2 | Declarative React chart library built on D3. Used for dashboard widgets (burndown, workload bar, status donut). Recharts is the right balance of customisability vs setup time for our use case. |
| Icons | Lucide React | Latest | The community-maintained successor to Feather Icons. ~1,500 consistent SVG icons, tree-shakeable so unused icons don't ship. Every icon in the app comes from one source for visual consistency. |
| Date | date-fns | v4 | Functional, immutable date utilities. No global state, no mutations, fully tree-shakeable. Used everywhere dates appear: due date pickers, timeline rendering, relative timestamps ("3 hours ago"). |
| Virtualisation | TanStack Virtual | v3 | Row virtualisation for the list and table views. Without it, rendering 5,000+ tasks destroys scroll performance. With it, only ~20 DOM nodes exist at a time regardless of dataset size. |
| Layer | Technology | Version | Why We Use It |
|---|---|---|---|
| Runtime | Node.js | 24 LTS ("Krypton") | Node 24 is the current Active LTS line (supported until April 2028). It ships V8 13.x (faster JIT, better memory), stable built-in SQLite, native fetch, and TypeScript type stripping via --experimental-strip-types — meaning we can run .ts files directly without a build step in development. Node 20 reached end of life April 2026. |
| API Server | Hono.js | v4 | Sub-millisecond cold starts, runs identically on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge. Express-like DX but fully typed. We chose Hono over Express because Express has no native TypeScript support and over Fastify because Hono's middleware model is simpler for our team. |
| ORM | Drizzle ORM | v0.x latest | The only TypeScript ORM where the query builder output is 1:1 with the SQL it generates — no magic, no surprises. Migrations are plain SQL files you review and commit. Drizzle is actively maintained and has become the de facto choice for new TypeScript projects in 2025–2026. |
| Database | PostgreSQL | 18.3 (latest stable) | Released September 2025. Key reasons to be on 18 vs 16: native uuidv7() function (perfect for our task IDs — sortable + unique), async I/O subsystem (up to 3× faster sequential scans), virtual generated columns (compute-on-read, no storage overhead), and OAuth 2.0 authentication support. Actively maintained until 2030. |
| Cache / Queue | Redis via Upstash | Redis 7.x | Used for three purposes: (1) session token cache to avoid DB hits on every request, (2) BullMQ job queue backend, (3) pub/sub for broadcasting realtime events to Socket.io instances. Upstash is serverless Redis — no infra to manage, pay-per-request pricing fits both self-host (swap for local Redis) and SaaS. |
| Background Jobs | BullMQ | v5 | Reliable, Redis-backed job queue with retries, rate limiting, priorities, and delayed jobs. Used for: sending notification emails, processing file uploads, running automation triggers, and rebuilding search indexes. |
| File Storage | S3-compatible (MinIO self-host / AWS S3 cloud) | — | MinIO is a drop-in S3-compatible server — self-hosters run it locally, our SaaS uses AWS S3. One abstraction, two deployment targets. Files are served via signed URLs (time-limited, no public buckets). |
| Search | Meilisearch | v1 | Full-text search across tasks, docs, comments, and projects. Sub-100ms results with typo tolerance. Meilisearch is simpler to operate than Elasticsearch and 10× faster to set up. It indexes our data asynchronously via BullMQ jobs on every write. |
| Realtime | Liveblocks | v2 | Collaborative editing presence (live cursors, who's-viewing-this-doc avatars, typing indicators) in documents. Liveblocks handles the WebSocket infrastructure and CRDT merge logic — building this ourselves would be a 6-month project. Self-hosted alternative: PartyKit. |
| Auth | Better Auth (self-host) / Clerk (SaaS fast path) | BA v1 | Better Auth is the new open-source standard replacing Lucia Auth (which was deprecated in late 2024). Supports email/password, magic links, OAuth (Google, GitHub), sessions, and 2FA out of the box. Clerk is used as the SaaS fast path — it handles email deliverability, bot protection, and MFA UI for paying customers. |
| React Email + Resend | RE v3 | React Email lets us build transactional emails as React components (typed, testable, previewed in browser). Resend is the delivery API — high deliverability, simple SDK, generous free tier. Emails are queued via BullMQ so a slow SMTP server never blocks a user action. | |
| WebSockets | Socket.io | v4 | Used for live task updates and notifications (someone assigned you a task, a status changed). Socket.io handles reconnection, room-based broadcasting, and fallback to long-polling for restrictive networks. Rooms map 1:1 to workspace IDs for clean isolation. |
| Layer | Technology | Version | Why We Use It |
|---|---|---|---|
| Monorepo | Nx | v21 | Full monorepo platform, not just a task runner. Chosen over Turborepo for four specific reasons: (1) nx generate scaffolds new apps, libraries, and components consistently — no contributor invents their own folder structure; (2) @nx/enforce-module-boundaries ESLint rule prevents the open-source core from accidentally importing proprietary SaaS billing code at the lint stage, not just by convention; (3) Nx Agents distributes CI tasks across multiple machines — critical once the test suite grows past 10 minutes; (4) nx graph gives a live visual dependency map, invaluable when onboarding OSS contributors. First-class plugins for Next.js (@nx/next), Node/Hono (@nx/node), Playwright (@nx/playwright), Docker, and Storybook. Remote caching via Nx Cloud (free tier for open-source). Turborepo would be the right call if we were a 2-person team who just wanted fast builds — we're not. |
| Package Manager | pnpm | v10 | Strict dependency isolation (phantom deps are impossible), content-addressable store saves disk space on CI, and workspaces-native. pnpm is the default choice for all modern TypeScript monorepos in 2026. |
| Container | Docker + Docker Compose | Docker 27 / Compose v2 | Single-command self-hosting (docker compose up -d). Each service (web, api, postgres, redis, meilisearch, minio) is a container. Reproducible environments across dev, staging, and production. |
| CI/CD | GitHub Actions | — | Free for open-source repos. Runs lint, type-check, unit tests, and E2E tests on every PR. Deploys to staging on merge to main, production on a release tag. Nx Cloud remote cache integrates natively, and Nx Agents can split long test runs across multiple CI machines. |
| Self-host Deploy | Railway / Coolify | — | Railway is the one-click PaaS for self-hosters who want managed infra. Coolify is the self-hosted alternative (runs on your own VPS). Both support Docker Compose deployments — same config, two deployment targets. |
| SaaS Deploy | Vercel (frontend) + Railway (backend) | — | Vercel for Next.js is unbeatable: edge network, ISR, automatic preview URLs per PR, and zero config. Railway hosts the Hono API, PostgreSQL, Redis, and Meilisearch. Both support environment variable management and zero-downtime deploys. |
| Monitoring | OpenTelemetry + Sentry | OTel v1 / Sentry v8 | OpenTelemetry gives us vendor-neutral traces and metrics. Sentry catches unhandled errors in both the Next.js frontend and Hono backend with full stack traces and source maps. Sentry's Session Replay is invaluable for reproducing user-reported bugs. |
| Analytics | PostHog | v3 | Open-source product analytics — self-hostable so no data leaves the user's server. Tracks activation, feature usage, funnels, and session recordings. GDPR-compliant by design. |
| API Docs | Scalar + OpenAPI 3.1 | — | Scalar generates a beautiful interactive API reference from our OpenAPI spec. Developers can test endpoints directly in the browser. We generate the OpenAPI spec from our Zod schemas so docs are always in sync with the code. |
| Testing | Vitest + Playwright | Vitest v3 / PW v1.5x | Vitest for unit and integration tests (100× faster than Jest, native ESM). Playwright for end-to-end tests — it's the only E2E framework that reliably handles Next.js server components, drag-and-drop interactions, and WebSocket-driven UI. |
- Metadata-driven architecture (like Twenty's object system) for custom fields
- GraphQL-inspired API layer via tRPC for type-safe end-to-end calls
- Workspace-based multi-tenancy (same as Twenty's approach)
- Open source core + cloud SaaS model identical to Twenty's strategy
flowspace/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── api/ # Hono.js backend
│ ├── docs/ # Documentation site (Nextra)
│ └── desktop/ # Electron wrapper (Phase 3)
├── packages/
│ ├── ui/ # Shared shadcn component library
│ ├── db/ # Drizzle schema, migrations, seeds
│ │ ├── schema/ # Table definitions (one file per domain)
│ │ ├── migrations/ # Auto-generated SQL migration files
│ │ ├── seeds/ # Seed scripts (dev | test | demo)
│ │ ├── metadata/ # Metadata engine (object/field registry)
│ │ └── drizzle.config.ts
│ ├── types/ # Shared TypeScript types
│ ├── auth/ # Auth utilities
│ ├── email/ # React Email templates
│ ├── config/ # ESLint, TS, Tailwind configs
│ └── utils/ # Shared utility functions
├── e2e/ # Playwright E2E test suite
│ ├── tests/
│ ├── fixtures/
│ └── playwright.config.ts
├── docker-compose.yml
├── docker-compose.test.yml # Isolated containers for CI
├── nx.json
└── pnpm-workspace.yaml
-- Workspaces (top-level tenant unit)
workspaces
id uuid PK
name text
slug text UNIQUE
logo_url text
plan text DEFAULT 'free' -- free | pro | business | enterprise
created_at timestamptz
settings jsonb DEFAULT '{}'
-- Users
users
id uuid PK
email text UNIQUE
name text
avatar_url text
created_at timestamptz
-- Workspace members
workspace_members
workspace_id uuid FK
user_id uuid FK
role text -- owner | admin | member | guest
joined_at timestamptz
PRIMARY KEY (workspace_id, user_id)
-- Spaces (like ClickUp Spaces — project groups)
spaces
id uuid PK
workspace_id uuid FK
name text
color text
icon text
is_private boolean DEFAULT false
created_at timestamptz
-- Projects (inside Spaces)
projects
id uuid PK
space_id uuid FK
name text
description text
status text -- active | archived | on_hold
color text
icon text
start_date date
due_date date
created_by uuid FK
created_at timestamptz
settings jsonb -- custom views, default statuses, etc.
-- Statuses (per project)
statuses
id uuid PK
project_id uuid FK
name text
color text
type text -- todo | in_progress | done | cancelled
position int
-- Tasks
tasks
id uuid PK
project_id uuid FK
parent_id uuid FK NULLABLE -- subtask support
title text
description jsonb -- BlockNote JSON
status_id uuid FK
priority text -- urgent | high | medium | low | none
assignee_id uuid FK
created_by uuid FK
start_date date
due_date date
estimated_hours decimal
actual_hours decimal
position decimal -- fractional indexing for ordering
tags text[]
custom_fields jsonb -- flexible metadata
created_at timestamptz
updated_at timestamptz
-- Comments
comments
id uuid PK
task_id uuid FK
user_id uuid FK
content jsonb -- rich text
parent_id uuid FK NULLABLE -- threaded replies
created_at timestamptz
updated_at timestamptz
reactions jsonb DEFAULT '{}'
-- Documents (Notion-like pages)
documents
id uuid PK
workspace_id uuid FK
project_id uuid FK NULLABLE -- can be standalone
parent_id uuid FK NULLABLE -- nested docs
title text
content jsonb -- BlockNote JSON
icon text
cover_url text
is_published boolean DEFAULT false
published_token text NULLABLE
created_by uuid FK
created_at timestamptz
updated_at timestamptz
-- Time Entries
time_entries
id uuid PK
task_id uuid FK
user_id uuid FK
started_at timestamptz
ended_at timestamptz NULLABLE
duration_seconds int NULLABLE
description text
is_running boolean DEFAULT false
-- Notifications
notifications
id uuid PK
user_id uuid FK
type text
payload jsonb
is_read boolean DEFAULT false
created_at timestamptz
-- Activity Log
activities
id uuid PK
workspace_id uuid FK
entity_type text -- task | document | project
entity_id uuid
user_id uuid FK
action text -- created | updated | deleted | commented
metadata jsonb
created_at timestamptz
-- Custom Fields Definitions
custom_field_definitions
id uuid PK
project_id uuid FK
name text
type text -- text | number | date | select | multi_select | user | url | checkbox
options jsonb NULLABLE -- for select types
position int
-- Integrations
integrations
id uuid PK
workspace_id uuid FK
provider text -- github | slack | google | figma | linear
config jsonb
access_token_encrypted text
created_at timestamptz
-- API Keys
api_keys
id uuid PK
workspace_id uuid FK
name text
key_hash text
last_used_at timestamptz
created_at timestamptz
expires_at timestamptz NULLABLEThis is the core architectural decision that separates FlowSpace from a plain CRUD app. Inspired directly by how Twenty CRM handles custom objects and fields — but adapted for project management.
Standard static schemas can't let users create their own fields at runtime (e.g. "Add a 'Customer Tier' dropdown to tasks in the Sales project"). You'd normally need a schema migration for every user customisation. The metadata engine solves this without jsonb blobs or EAV anti-patterns.
Every workspace gets two PostgreSQL schemas (not tables — actual PG schemas):
PostgreSQL Instance
├── core (schema) ← System tables: workspaces, users, members, billing
│ ├── workspaces
│ ├── users
│ ├── workspace_members
│ └── ...
│
└── workspace_{slug} (schema) ← Dynamically created per workspace on signup
├── task ← Standard object
├── project ← Standard object
├── document ← Standard object
├── _field_metadata ← Registry of all fields (standard + custom)
├── _object_metadata ← Registry of all objects (standard + custom)
└── custom_pet_tracker ← Example custom object created by user
When a new workspace signs up, the API runs CREATE SCHEMA workspace_{slug} and then applies the standard object migrations into that schema. This gives every tenant fully isolated tables — not row-level isolation with a workspace_id column.
-- Object registry: every "thing" in the system
_object_metadata
id uuid PK DEFAULT uuidv7()
workspace_id uuid
name_singular text -- 'task'
name_plural text -- 'tasks'
description text
icon text -- 'CheckSquare'
is_system boolean -- true = standard, false = user-created
is_active boolean DEFAULT true
created_at timestamptz DEFAULT now()
-- Field registry: every field on every object
_field_metadata
id uuid PK DEFAULT uuidv7()
object_id uuid FK → _object_metadata.id
name text -- 'priority'
label text -- 'Priority'
type text -- see FieldType enum below
description text
icon text
is_system boolean
is_required boolean DEFAULT false
is_unique boolean DEFAULT false
default_value jsonb
options jsonb -- for SELECT / MULTI_SELECT: [{value, label, color}]
settings jsonb -- type-specific config (e.g. min/max for NUMBER)
position integer -- display order
created_at timestamptz DEFAULT now()
-- Relation registry: links between objects
_relation_metadata
id uuid PK DEFAULT uuidv7()
from_object_id uuid FK → _object_metadata.id
to_object_id uuid FK → _object_metadata.id
type text -- ONE_TO_MANY | MANY_TO_MANY | ONE_TO_ONE
from_field_name text -- column name on from_object's table
to_field_name text -- column name on to_object's table
on_delete text -- CASCADE | SET_NULL | RESTRICTenum FieldType {
// Primitives
TEXT = 'TEXT', // varchar
RICH_TEXT = 'RICH_TEXT', // jsonb (BlockNote)
NUMBER = 'NUMBER', // decimal
BOOLEAN = 'BOOLEAN', // boolean
DATE = 'DATE', // date
DATE_TIME = 'DATE_TIME', // timestamptz
// Choice fields
SELECT = 'SELECT', // text with options validation
MULTI_SELECT= 'MULTI_SELECT', // text[] with options validation
// Relational
RELATION = 'RELATION', // FK to another object
ARRAY = 'ARRAY', // jsonb array
// Special
URL = 'URL', // {label, url}
EMAIL = 'EMAIL', // text + validation
PHONE = 'PHONE', // text + validation
CURRENCY = 'CURRENCY', // {amount_micros, currency_code}
RATING = 'RATING', // integer 1-5
POSITION = 'POSITION', // float8 fractional index
UUID = 'UUID', // uuid
}When a user adds a custom field (e.g. "Customer Tier" select on Task):
- API creates the field metadata record in
_field_metadata - API runs DDL dynamically:
ALTER TABLE workspace_acme.task ADD COLUMN customer_tier text - API validates options against the metadata on every write
- Frontend reads
_field_metadatato render the correct input type and label — no frontend deploy needed
This is exactly the pattern Twenty uses (they call it the Metadata Engine). The key difference from a jsonb blob approach: all custom fields are real PostgreSQL columns, so you get indexes, constraints, type safety, and fast queries.
GET /meta/objects → List all object types + fields
GET /meta/objects/:name → Get full object schema with all fields
POST /meta/objects → Create custom object (creates PG table)
POST /meta/objects/:name/fields → Add custom field (ALTER TABLE)
PUT /meta/fields/:id → Update field label/options/settings
DEL /meta/fields/:id → Drop column + delete metadata record
We run two separate migration pipelines because we have two schema categories.
Layer 1 — Core Schema Migrations (standard Drizzle Kit) Static tables that never change per-workspace (users, workspaces, billing). These are normal Drizzle Kit migrations committed to source control.
packages/db/
├── schema/
│ ├── core/
│ │ ├── workspaces.ts
│ │ ├── users.ts
│ │ ├── workspace-members.ts
│ │ └── index.ts
│ └── workspace/
│ ├── tasks.ts
│ ├── projects.ts
│ ├── documents.ts
│ ├── metadata.ts ← _object_metadata, _field_metadata tables
│ └── index.ts
├── migrations/
│ ├── core/ ← Never touched after deploy
│ │ └── 0001_init.sql
│ └── workspace/ ← Applied to every new workspace schema
│ ├── 0001_init_standard_objects.sql
│ └── 0002_add_time_tracking.sql
├── seeds/
└── drizzle.config.ts
Layer 2 — Workspace Schema Migrations (metadata-driven, runtime)
When a user adds a custom field, we run ALTER TABLE directly. This is NOT a Drizzle migration — it's generated at runtime by the metadata engine and logged in a _migrations_log table inside each workspace schema for audit and rollback.
-- Logged in workspace_{slug}._migrations_log
_migrations_log
id uuid PK DEFAULT uuidv7()
type text -- 'ADD_COLUMN' | 'DROP_COLUMN' | 'ALTER_COLUMN' | 'CREATE_TABLE'
sql text -- the exact DDL that ran
object_name text
field_name text
executed_by uuid FK -- user_id who triggered it
executed_at timestamptz DEFAULT now()
rolled_back boolean DEFAULT false# Generate migration from schema changes
pnpm nx run db:migrate:generate --name=add_time_tracking
# Apply pending migrations to all workspace schemas
pnpm nx run db:migrate:run
# Apply migrations to a specific workspace only
pnpm nx run db:migrate:run -- --workspace=acme-corp
# Rollback last migration
pnpm nx run db:migrate:rollback
# Check migration status
pnpm nx run db:migrate:status
# Reset entire database (dev only — DANGEROUS)
pnpm nx run db:migrate:reset
# Introspect existing DB → generate schema types
pnpm nx run db:migrate:introspectimport { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './schema/**/*.ts',
out: './migrations',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
migrations: {
table: '__drizzle_migrations',
schema: 'core',
},
// Strict mode: no IF NOT EXISTS — fail loudly on conflicts
strict: true,
verbose: true,
});PR opened
└── nx affected --target=test (unit tests against test DB)
Merge to main
└── nx run db:migrate:run (apply to staging DB)
└── nx run api:e2e (E2E against staging)
Release tag
└── nx run db:migrate:run (apply to production DB)
└── Zero-downtime: migrations are always backwards-compatible
(add columns with DEFAULT, never drop without deprecation period)
Three separate seed environments — each a standalone Nx target.
| Command | Purpose | Data Volume |
|---|---|---|
db:seed:dev |
Local dev — rich, realistic data | ~500 tasks, 3 workspaces, 10 users |
db:seed:test |
CI / unit test isolation | Minimal deterministic fixtures |
db:seed:demo |
Live demo instance on flowspace.com | Curated showcase data |
db:seed:reset |
Wipe + reseed dev DB | Calls migrate:reset then seed:dev |
packages/db/seeds/
├── data/
│ ├── users.ts ← Deterministic users (same IDs every run)
│ ├── workspaces.ts
│ ├── projects.ts
│ ├── tasks.ts ← ~500 tasks with realistic titles
│ ├── documents.ts ← Sample PRD, meeting notes, wiki pages
│ ├── comments.ts
│ └── time-entries.ts
├── dev.seed.ts ← Entry point for dev seed
├── test.seed.ts ← Entry point for test seed
├── demo.seed.ts ← Entry point for demo seed
└── utils/
├── faker.ts ← @faker-js/faker wrappers
└── truncate.ts ← TRUNCATE ... CASCADE helper
// packages/db/seeds/dev.seed.ts
import { db } from '../client';
import { seedUsers } from './data/users';
import { seedWorkspaces } from './data/workspaces';
import { seedProjects } from './data/projects';
import { seedTasks } from './data/tasks';
import { truncateAll } from './utils/truncate';
async function main() {
console.log('🌱 Seeding dev database...');
await truncateAll(db); // clean slate
const users = await seedUsers(db); // 10 fixed users
const workspaces = await seedWorkspaces(db, users);
const projects = await seedProjects(db, workspaces);
const tasks = await seedTasks(db, projects, users);
console.log(`✅ Seeded:
${users.length} users
${workspaces.length} workspaces
${projects.length} projects
${tasks.length} tasks`);
}
main().catch(console.error).finally(() => process.exit());Test seeds use fixed UUIDs (not random) so assertions can reference them directly:
// packages/db/seeds/data/users.ts
export const SEED_USERS = {
ADMIN: {
id: '00000000-0000-0000-0000-000000000001',
email: 'admin@test.flowspace.dev',
name: 'Test Admin',
role: 'owner',
},
MEMBER: {
id: '00000000-0000-0000-0000-000000000002',
email: 'member@test.flowspace.dev',
name: 'Test Member',
role: 'member',
},
GUEST: {
id: '00000000-0000-0000-0000-000000000003',
email: 'guest@test.flowspace.dev',
name: 'Test Guest',
role: 'guest',
},
} as const;
export const SEED_PROJECTS = {
MAIN: {
id: '00000000-0000-0000-0000-000000000010',
name: 'Test Project Alpha',
},
} as const;pnpm nx run db:seed:dev # Seed local dev DB
pnpm nx run db:seed:test # Seed test DB (called by Vitest globalSetup)
pnpm nx run db:seed:demo # Seed demo instance
pnpm nx run db:seed:reset # migrate:reset + seed:devAll E2E tests run against a real browser + real database — no mocks, no stubs. Every test starts from a known DB state via the test seed.
e2e/
├── tests/
│ ├── auth/
│ │ ├── signup.spec.ts
│ │ ├── login.spec.ts
│ │ └── invite.spec.ts
│ ├── tasks/
│ │ ├── create-task.spec.ts
│ │ ├── kanban-drag.spec.ts
│ │ ├── task-detail.spec.ts
│ │ └── bulk-actions.spec.ts
│ ├── projects/
│ │ ├── create-project.spec.ts
│ │ └── views.spec.ts
│ ├── documents/
│ │ ├── create-doc.spec.ts
│ │ └── publish.spec.ts
│ ├── search/
│ │ └── global-search.spec.ts
│ ├── settings/
│ │ ├── custom-fields.spec.ts ← Tests metadata engine
│ │ └── workspace.spec.ts
│ └── billing/
│ └── upgrade-plan.spec.ts ← Stripe test mode
├── fixtures/
│ ├── auth.fixture.ts ← Pre-authenticated page objects
│ ├── db.fixture.ts ← DB client for assertion helpers
│ └── workspace.fixture.ts
├── page-objects/ ← POM pattern
│ ├── TaskPage.ts
│ ├── KanbanBoard.ts
│ ├── CommandMenu.ts
│ └── DocumentEditor.ts
├── utils/
│ ├── api-helpers.ts ← Direct API calls to set up state faster
│ └── db-helpers.ts
└── playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html'],
['github'], // inline annotations in PRs
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// Run in Chromium + Firefox + Mobile Chrome
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
// Start the app + seed DB before tests
globalSetup: './utils/global-setup.ts',
globalTeardown: './utils/global-teardown.ts',
webServer: {
command: 'pnpm nx run web:dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});// e2e/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
import { SEED_USERS } from '@flowspace/db/seeds';
type AuthFixtures = {
adminPage: Page;
memberPage: Page;
};
export const test = base.extend<AuthFixtures>({
adminPage: async ({ browser }, use) => {
// Load saved auth state — avoids full login on every test
const ctx = await browser.newContext({
storageState: 'e2e/.auth/admin.json',
});
await use(await ctx.newPage());
await ctx.close();
},
memberPage: async ({ browser }, use) => {
const ctx = await browser.newContext({
storageState: 'e2e/.auth/member.json',
});
await use(await ctx.newPage());
await ctx.close();
},
});
export { expect } from '@playwright/test';// e2e/page-objects/KanbanBoard.ts
import { Page, Locator } from '@playwright/test';
export class KanbanBoard {
readonly page: Page;
readonly columns: Locator;
constructor(page: Page) {
this.page = page;
this.columns = page.locator('[data-testid="kanban-column"]');
}
column(name: string) {
return this.page.locator(`[data-testid="kanban-column"][data-name="${name}"]`);
}
card(title: string) {
return this.page.locator(`[data-testid="kanban-card"][data-title="${title}"]`);
}
async dragCardToColumn(cardTitle: string, targetColumn: string) {
const card = this.card(cardTitle);
const target = this.column(targetColumn);
await card.dragTo(target);
}
async createCard(column: string, title: string) {
await this.column(column).locator('[data-testid="add-card"]').click();
await this.page.keyboard.type(title);
await this.page.keyboard.press('Enter');
}
}// e2e/tests/tasks/kanban-drag.spec.ts
import { test, expect } from '../../fixtures/auth.fixture';
import { KanbanBoard } from '../../page-objects/KanbanBoard';
test.describe('Kanban board', () => {
test.beforeEach(async ({ adminPage }) => {
await adminPage.goto('/acme-corp/engineering/backend-api');
});
test('drag task from Todo to In Progress', async ({ adminPage }) => {
const board = new KanbanBoard(adminPage);
await expect(board.column('Todo')).toBeVisible();
await board.dragCardToColumn('Fix auth bug', 'In Progress');
// Assert DB state directly via API helper
await expect(adminPage.locator('[data-testid="toast-success"]'))
.toContainText('Task updated');
await expect(board.column('In Progress').locator('text=Fix auth bug'))
.toBeVisible();
});
test('create a new card inline', async ({ adminPage }) => {
const board = new KanbanBoard(adminPage);
await board.createCard('Todo', 'New E2E test task');
await expect(board.card('New E2E test task')).toBeVisible();
});
});# .github/workflows/e2e.yml
- name: Run E2E tests
run: |
docker compose -f docker-compose.test.yml up -d
pnpm nx run db:migrate:run
pnpm nx run db:seed:test
pnpm nx run e2e:test --ci
docker compose -f docker-compose.test.yml down
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: e2e/playwright-report/| Area | Critical Paths Covered |
|---|---|
| Auth | Signup, magic link, Google OAuth, invite flow |
| Tasks | Create, update, delete, drag-drop, bulk actions, custom fields |
| Kanban | All views, column reorder, WIP limits |
| Documents | Create, edit with BlockNote, publish to web, search |
| Metadata | Add custom field → appears in UI → persists after reload |
| Search | Task search, doc search, cross-entity results |
| Settings | Invite member, change role, generate API key |
| Billing | Upgrade to Pro (Stripe test mode), feature gate enforcement |
Routes: /login /signup /invite/[token] /onboarding
Features:
- Email + password signup
- Magic link (passwordless)
- Google OAuth, GitHub OAuth
- Workspace invite links (expirable, role-scoped)
- Onboarding wizard: create workspace → invite team → create first project → choose template
- Profile settings: name, avatar, timezone, notification preferences
Security:
- JWT access tokens (15min) + refresh tokens (30d) in httpOnly cookies
- PKCE for OAuth flows
- Rate limiting on auth endpoints (Upstash Redis)
- Email verification required before workspace creation
Routes: /[workspace-slug]/ /[workspace-slug]/settings
Workspace:
- Unique slug-based URLs (e.g.
app.flowspace.com/acme-corp) - Custom logo + branding
- Plan management
- Danger zone: export all data, delete workspace
Spaces:
- Color + icon per space
- Private spaces (invite-only)
- Space-level member management
- Space templates (Engineering, Marketing, Design, etc.)
Routes: /[workspace]/[space]/[project]/
1. List View
- Grouped by status by default
- Collapsible groups
- Inline task creation in any group
- Sort by: due date, priority, assignee, created date, custom fields
- Column visibility toggle
- Bulk select + bulk actions (assign, change status, delete)
- Custom field columns visible inline
2. Kanban Board View
- Drag tasks between status columns
- Drag to reorder within column (fractional indexing)
- Collapsible columns
- Column WIP limits (optional)
- Swimlanes: group by assignee, priority, or custom field
- Quick add card at bottom of each column
- Card previews: priority badge, assignee avatar, due date, subtask count
3. Timeline / Gantt View
- Date-based horizontal bars per task
- Drag to reschedule, drag edges to resize duration
- Group by: status, assignee, project
- Dependency arrows (task A blocks task B)
- Today line indicator
- Zoom: day / week / month / quarter
4. Calendar View
- Monthly / weekly grid
- Tasks plotted by due date
- Drag to reschedule
- Multi-day tasks span across days
- Quick create by clicking a date
5. Table View
- Spreadsheet-style with all fields as columns
- Add custom field columns
- Sort, filter, group by any column
- Inline edit any cell
- Row height: compact / comfortable / spacious
- Freeze first column
6. Dashboard View (per project)
- Drag-and-drop widget grid (react-grid-layout)
- Widgets: task count by status (donut), burndown chart, team workload (bar), overdue tasks list, recent activity feed, time tracked this week
- Saveable dashboard layouts
Routes: Right panel slide-over OR /[workspace]/task/[taskId] (full page)
Core fields:
- Title (inline edit)
- Status (dropdown)
- Priority (urgent / high / medium / low / none)
- Assignee (single or multiple)
- Due date + start date (date picker)
- Tags (autocomplete, create new)
- Time estimate
- Parent task (breadcrumb for subtasks)
Rich description:
- BlockNote editor (slash commands)
- Blocks: paragraph, heading 1-3, bullet list, numbered list, checklist, quote, callout, divider, image, video embed, code block, table, toggle, mention (@user, #task, [[doc]])
- Drag blocks to reorder
- Paste URLs → auto-embed (Figma, Loom, YouTube, GitHub PR)
Subtasks:
- Unlimited nesting (max 5 levels recommended)
- Progress bar on parent showing subtask completion %
- Convert subtask to top-level task
Comments:
- Rich text comments (same block editor, simpler palette)
- Threaded replies
- Emoji reactions
- @mentions with notification
- Edit + delete own comments
- Resolve/unresolve comment threads
Attachments:
- Drag-and-drop file upload
- Image preview inline
- S3 storage with signed URLs
- Max 25MB per file (free) / 250MB (pro)
Activity Log:
- Every field change logged: who, what, when
- Chronological timeline on task
Custom Fields:
- Text, Number, Date, Select, Multi-select, User, URL, Checkbox, Formula (Phase 2)
- Defined per project, visible on task detail + table view
Time Tracking:
- Start/stop timer on any task
- Manual time entry
- Per-task time log
- Workspace-wide time reports
Routes: /[workspace]/docs/ /[workspace]/docs/[docId]
Features:
- Nested page tree (sidebar)
- Drag to reorder / nest
- BlockNote full editor (same as task description, extended with full-width blocks)
- Page icon (emoji or custom) + cover image
- Breadcrumb navigation
- Link to tasks inline:
/task TASK-123 - Backlinks panel (which docs link to this doc)
- Full-text search (Meilisearch)
- Version history (last 100 versions, pro: unlimited)
- Publish to web: toggle → get shareable read-only URL
- Export: Markdown, PDF
- Import: Markdown, Notion export (JSON zip)
- Templates: meeting notes, PRD, retrospective, weekly update
Realtime Presence:
- Avatars showing who is viewing same task/doc
- Live cursor positions in docs (Liveblocks)
- Typing indicators in comments
Notifications:
- In-app notification center (bell icon)
- Email digests (immediate / hourly / daily — user preference)
- Notification types: @mention, assigned to task, task due tomorrow, comment reply, task status changed, doc shared with you
- Mark all as read, archive
Team Inbox:
- All items assigned to me
- Filtered views: overdue, due today, due this week, recently updated
@Mentions:
- Works across tasks, comments, docs
- Creates notification to mentioned user
- Highlights mention in rendered content
Global Search (Cmd/Ctrl + K):
- Fuzzy search across tasks, docs, projects, comments
- Powered by Meilisearch
- Filters: type, project, assignee, date range, status
- Recent searches
- Keyboard navigation
- Results grouped by type with icons
Automations (Phase 2):
- Trigger → Condition → Action model
- Triggers: task created, status changed, due date approaches, custom field changed, comment added
- Actions: assign user, change status, send notification, create subtask, post to Slack, move to project, set custom field
- Visual automation builder (no-code)
- Pre-built automation templates
Phase 1 (MVP):
- Slack: post activity, task create from slash command
- GitHub: link PRs to tasks, auto-close tasks on merge
- Google Calendar: sync due dates
Phase 2:
- Figma: embed designs in tasks/docs
- Linear: bi-directional sync
- Zapier / Make webhooks
- Google Drive: attach files
- Loom: embed videos
Developer API:
- REST API (documented with Scalar)
- API key management in workspace settings
- Webhooks for all entity events
- Rate limits: 1000 req/min (pro), 100 req/min (free)
- SDK: official TypeScript client (Phase 2)
Workspace Settings:
- General: name, slug, logo, timezone
- Members: invite, role management, remove
- Billing: plan, payment method, invoices
- Integrations
- API Keys
- Import / Export
- Danger zone
Personal Settings:
- Profile: name, avatar, email
- Notifications preferences
- Appearance: light / dark / system
- Language / timezone
- Connected accounts (Google, GitHub)
- Active sessions
Color Tokens:
/* Brand */
--brand-primary: #5B4CF5 /* violet */
--brand-secondary: #8B7BF8
/* Neutral (based on Twenty's palette approach) */
--gray-50 through --gray-950
/* Semantic */
--status-todo: #94A3B8
--status-in-progress: #6366F1
--status-done: #22C55E
--status-cancelled: #EF4444
/* Priority */
--priority-urgent: #EF4444
--priority-high: #F97316
--priority-medium: #F59E0B
--priority-low: #6366F1
--priority-none: #94A3B8Typography:
- Font: Geist Sans (same as Vercel/Linear aesthetic)
- Mono: Geist Mono
- Scale: 12 / 13 / 14 / 16 / 18 / 20 / 24 / 32 / 40px
Layout:
- Left sidebar: 240px (collapsible to 48px icon rail)
- Right panel (task detail): 480px slide-over
- Main content: fluid remaining width
- Top bar: 48px fixed
Sidebar Structure:
[Workspace Logo + Name ▾]
─────────────────────
⌕ Search Cmd+K
⌂ Home
📥 My Tasks
🔔 Notifications
─────────────────────
SPACES
▾ Engineering
├ 📋 Backend API
├ 📋 Frontend
└ + New Project
▾ Marketing
└ 📋 Q1 Campaign
+ New Space
─────────────────────
📄 Docs
─────────────────────
⚙ Settings
[Avatar] [Name]
Design Principles:
- Density matters — compact default, spacious optional. Power users live in list view.
- Keyboard first — every action has a shortcut. Modal for shortcut reference (
?) - Optimistic UI — all mutations update immediately, rollback on error. Never block user.
- No dead ends — empty states have clear CTAs. First-time UX is guided.
- Progressive disclosure — basic fields visible, advanced fields behind "More options"
Keyboard Shortcuts:
Cmd+K Global search
Cmd+N New task
Cmd+/ Toggle sidebar
C Create task (when in project view)
G then H Go to Home
G then M Go to My Tasks
Cmd+Enter Submit (forms, comments)
Escape Close modals/panels
1-4 Switch views (List/Board/Timeline/Calendar)
? Open shortcuts modal
All API calls go through tRPC routers for type safety. Also exposes REST for external integrations.
Base URL: https://api.flowspace.com/v1
Core Routers (tRPC):
// Tasks
tasks.create
tasks.update
tasks.delete
tasks.list
tasks.get
tasks.addComment
tasks.listComments
tasks.startTimer
tasks.stopTimer
// Projects
projects.create
projects.update
projects.delete
projects.list
// Docs
docs.create
docs.update
docs.delete
docs.list
docs.publish
// Workspaces
workspaces.create
workspaces.update
workspaces.listMembers
workspaces.inviteMember
workspaces.removeMember
// Users
users.me
users.update
users.notificationsREST Endpoints (external API):
GET /v1/tasks
POST /v1/tasks
GET /v1/tasks/:id
PATCH /v1/tasks/:id
DELETE /v1/tasks/:id
GET /v1/projects
POST /v1/projects
GET /v1/projects/:id
GET /v1/docs
POST /v1/docs
POST /v1/webhooks
DELETE /v1/webhooks/:id
Webhook Payload:
{
"event": "task.status_changed",
"timestamp": "2025-01-01T00:00:00Z",
"workspace_id": "uuid",
"data": {
"task_id": "uuid",
"previous_status": "todo",
"new_status": "in_progress",
"changed_by": "user_id"
}
}| Feature | Free | Pro | Business | Enterprise |
|---|---|---|---|---|
| Members | 5 | Unlimited | Unlimited | Unlimited |
| Spaces | 3 | Unlimited | Unlimited | Unlimited |
| Projects per space | 5 | Unlimited | Unlimited | Unlimited |
| Storage | 1GB | 50GB | 250GB | Custom |
| File size limit | 5MB | 25MB | 100MB | Custom |
| Doc version history | 30 days | Unlimited | Unlimited | Unlimited |
| API calls | 100/min | 1000/min | 5000/min | Custom |
| Automations | 5 | 100 | Unlimited | Unlimited |
| Custom fields | 5 | 50 | Unlimited | Unlimited |
| Integrations | 2 | All | All + Priority | Custom |
| Guest access | ✗ | ✓ | ✓ | ✓ |
| SSO / SAML | ✗ | ✗ | ✓ | ✓ |
| Audit logs | ✗ | 30 days | 1 year | Unlimited |
| SLA | ✗ | ✗ | 99.9% | 99.99% |
| Price | Free | $12/user/mo | $20/user/mo | Custom |
Billing via Stripe:
- Stripe Checkout for subscription
- Stripe Customer Portal for self-service plan changes
- Annual billing = 2 months free (17% discount)
- Failed payment grace period: 7 days → downgrade to free
Goal: Ship a usable product. Public open-source repo. Basic cloud SaaS.
- Monorepo setup (Nx + pnpm)
- Auth (email/password + Google OAuth)
- Workspace + Space + Project CRUD
- Task CRUD (title, status, assignee, due date, priority)
- List View + Kanban Board View
- Task detail panel (description with BlockNote, comments)
- Basic notifications (in-app)
- Documents (basic page creation + nested pages)
- Global search (Meilisearch)
- Docker Compose for self-hosting
- Deployment: Vercel + Railway
- Landing page (flowspace.com)
- Basic docs site
Open Source Goals:
- Public GitHub repo from day 1
- MIT license on core (AGPL for cloud-specific features)
- CONTRIBUTING.md, good first issues tagged
- GitHub Discussions for community
Goal: Feature parity with ClickUp mid-tier. Paying customers.
- Timeline / Gantt view
- Calendar view
- Table view with custom columns
- Custom fields (all types)
- Time tracking
- Automations builder
- Dashboard widgets
- Slack + GitHub integrations
- Version history (docs)
- Subtasks (multi-level)
- Bulk actions
- Import from Notion / ClickUp / CSV
- REST API + API key management
- Webhooks
- Stripe billing integration
- Email notifications (Resend)
- Mobile-responsive web
Goal: Enterprise-ready. Desktop app. Ecosystem.
- SSO / SAML
- Audit logs
- Advanced permissions (field-level)
- Figma + Loom integrations
- Zapier / Make native integration
- TypeScript SDK (npm package)
- Electron desktop app
- White-label for Enterprise
- AI features: task summarization, auto-assign, smart due dates (Claude API)
- Analytics dashboards
- Portfolio view (cross-project reporting)
- Resource management (team capacity)
- Forms (public intake → task creation)
apps/web— MIT licensedapps/api— MIT licensed (core)apps/cloud— Proprietary (SaaS-specific features: billing, SSO, audit logs)
This is the Open Core model used by GitLab, Metabase, Plane.so.
- Launch on GitHub → ProductHunt → Hacker News
- Good docs + one-command self-host (
docker compose up) - First 100 GitHub stars → write blog post on dev.to
- Community features: integrations contributed by OSS contributors
- Convert top OSS users → cloud SaaS (they're already users, just on their own server)
# One command to self-host
git clone https://github.com/flowspace/flowspace
cd flowspace
cp .env.example .env
docker compose up -d
# App live at http://localhost:3000| Metric | Target |
|---|---|
| Page load (LCP) | < 1.5s |
| Task open (interaction) | < 200ms |
| Search results | < 100ms |
| Kanban drag response | < 16ms (60fps) |
| API P99 latency | < 300ms |
| Uptime (SaaS) | 99.9% |
| Time to first task | < 5 min (onboarding) |
Optimizations:
- React Server Components for initial renders
- Optimistic mutations (Zustand + TanStack Query)
- Virtualized lists (TanStack Virtual) for 10,000+ tasks
- Debounced search (300ms)
- Image optimization via Next.js Image
- Fractional indexing for drag-and-drop order (no renumbering)
- Database indexes on all FK columns + frequently filtered columns
- Redis cache for workspace member lists, project metadata
- All user data encrypted at rest (AES-256)
- TLS 1.3 in transit
- Workspace data isolation at DB level (workspace_id on every query)
- OWASP Top 10 compliance
- Rate limiting on all public endpoints
- File uploads: virus scan (ClamAV), type validation, SSRF protection
- Content Security Policy headers
- No third-party analytics that share user data
- GDPR compliance: data export, right to deletion
- SOC2 Type II (target: Month 18)
- All utility functions
- tRPC router logic
- Drizzle query builders
- API endpoints with test DB
- Auth flows
- File upload/download
- Critical paths: signup → create workspace → create task → assign → comment
- Kanban drag and drop
- Document creation + publish
- Payment flow (Stripe test mode)
- k6 load testing for API endpoints
- Lighthouse CI on every PR for frontend
Activation: % of signups who create first task within 24h Engagement: DAU/MAU ratio Retention: Week 1, Week 4, Month 3 retention curves Revenue: MRR, ARR, churn rate, LTV Product: Most used views, task completion rates, doc creation rate Self-host: Docker pulls, GitHub stars (proxy for OSS health)
| Product | Price | Open Source | Best At |
|---|---|---|---|
| FlowSpace | Free–$20/user | ✅ Core | Balance of power + simplicity |
| ClickUp | Free–$19/user | ✗ | Feature breadth |
| Notion | Free–$16/user | ✗ | Docs + flexibility |
| Linear | Free–$18/user | ✗ | Engineering teams |
| Plane.so | Free–$6/user | ✅ | ClickUp OSS clone |
| Jira | $0–$16/user | ✗ | Enterprise / complex workflows |
FlowSpace wins on:
- Open source (no vendor lock-in) vs ClickUp/Notion/Linear
- Better UX than Jira (much simpler)
- More structured than Notion (actual project management)
- More powerful than Plane.so (docs + automations + custom fields)
- Better design + DX than traditional OSS tools
- Landing page live (waitlist)
- GitHub repo public (README, good first issues)
- Self-host Docker Compose working
- Core MVP features complete
- Stripe integration tested
- Error monitoring (Sentry) active
- Analytics (PostHog) active
- GDPR privacy policy + ToS
- ProductHunt submission prepared (screenshots, GIF demo)
- Hacker News "Show HN" post written
- Twitter/X thread written
- Dev.to article: "Why I built an open-source ClickUp alternative"
- YouTube demo video (5 min)
- Respond to every GitHub issue within 24h
- Discord server for community
- Blog post: "We hit 500 GitHub stars"
- Collect user interviews (10 users, 30 min each)
Document version: 1.0 | Written as senior developer specification | All features subject to user validation before build