Products that combine a visual canvas with a flexible database look simple when they are empty. Add real users, custom fields, relations, permissions, realtime sync, undo/redo, and large workspaces, and the product becomes an architecture problem quickly.
While leading work on OLAB at Yadn, I worked on a visual canvas similar to Draw.io, a Notion-style database system, a custom document editor, and realtime product workflows. The biggest lesson was that flexible product primitives are powerful because they are abstract, but they are difficult for the same reason.
This article explains how I would architect a visual canvas and database product using React Flow, TanStack Table, Supabase, and a pragmatic realtime model.
Why This Product Is Hard
A typical CRUD app has known shapes. A flexible workspace product allows users to create the shapes.
Users expect:
- Canvas nodes and edges.
- Dragging, zooming, grouping, and layout.
- Tables with custom columns.
- Relations between records.
- Rollups and computed fields.
- Inline editing.
- Documents with embedded tables and canvases.
- Realtime collaboration.
- Permissions by workspace, document, table, and item.
The product is not one feature. It is a system of primitives.
High-Level Architecture
flowchart TD
UI[Next.js UI] --> Canvas[React Flow Canvas]
UI --> Table[TanStack Table]
UI --> Editor[Tiptap Editor]
UI --> API[Application API]
API --> Supabase[(Supabase Postgres)]
API --> Storage[File / Asset Storage]
Supabase --> Realtime[Realtime Sync]
Realtime --> UI
API --> Permissions[Permission Service]
The frontend needs strong local state because interactions are rich. The backend needs strong relational modeling because flexible data becomes messy without constraints.
Canvas Model
A canvas should not store only visual coordinates. It should store product meaning.
Core entities:
workspacecanvascanvas_nodecanvas_edgecanvas_versioncanvas_asset
Node fields:
- ID.
- Type.
- Position.
- Size.
- Style.
- Data payload.
- Parent/group ID.
- Version.
- Updated by.
Edges should reference node IDs and include type, labels, direction, and metadata.
React Flow can render and manage interactions, but the persisted model should remain your own. Do not let a UI library dictate your full domain schema.
Implementing Canvas Persistence
For React Flow, keep UI state responsive and persist meaningful changes through mutations.
Example node table:
create table canvas_nodes (
id uuid primary key default gen_random_uuid(),
canvas_id uuid not null references canvases(id),
type text not null,
position_x numeric not null default 0,
position_y numeric not null default 0,
width numeric,
height numeric,
data jsonb not null default '{}',
version int not null default 1,
updated_by uuid,
updated_at timestamptz not null default now()
);
When a user drags a node, do not persist every movement. Persist the final position or a debounced update:
const persistNodeMove = debounce(async (node: Node) => {
await api.patch(`/canvases/${canvasId}/nodes/${node.id}`, {
position: node.position,
expectedVersion: node.data.version,
});
}, 300);
On the backend, check the version:
async updateNodePosition(input: UpdateNodePositionInput) {
const node = await this.nodes.findById(input.nodeId);
if (node.version !== input.expectedVersion) {
throw new ConflictException("Node was updated by another session");
}
return this.nodes.update(input.nodeId, {
positionX: input.position.x,
positionY: input.position.y,
version: node.version + 1,
});
}
This gives you a basic conflict boundary without overengineering a full CRDT system on day one.
Document And Embed Boundaries
Products like OLAB-style workspaces often combine documents, canvases, and databases. The hard part is deciding what owns what.
A document can embed a canvas. A canvas node can reference a database record. A database row can link to a document. If every object directly owns every other object, the system becomes tangled.
Prefer references over deep nesting:
- A document block references a canvas ID.
- A canvas node references a record ID.
- A table cell references a document ID.
- Permissions are resolved through workspace and resource rules.
This keeps each primitive independent enough to evolve. Deleting a document should not accidentally delete a shared canvas used elsewhere unless the product explicitly models ownership that way.
Database Product Model
For a Notion-style database, users define tables and fields.
Core entities:
databasedatabase_viewfieldrecordcell_valuerelationrollup_definition
The hardest decision is how to store cell values. Fully normalized cell rows are flexible but can become expensive. JSON blobs are fast to read but harder to query and validate.
A pragmatic approach:
- Store field definitions separately.
- Store records with stable IDs.
- Store cell values in a flexible structure.
- Promote high-value fields to indexed columns only when the product needs it.
Do not overbuild query flexibility before you know the workflows.
Relations And Rollups
Relations and rollups are where flexible databases become serious products.
A relation field connects records across tables. A rollup field computes something from related records: count, sum, latest date, status summary, or custom aggregation.
Design questions:
- Are relations one-way or bidirectional?
- Do deleted records leave references behind?
- Are rollups computed on read or precomputed?
- How are permission boundaries handled across related tables?
- What happens when a field type changes?
For small products, computing rollups on read may be acceptable. For larger workspaces, precomputing or caching rollups becomes important. The tradeoff is freshness versus cost.
A safe implementation path:
- Start with explicit relation records.
- Compute simple rollups on read.
- Add background recomputation for expensive rollups.
- Cache rollup output with invalidation events.
- Add admin/debug tools to inspect dependency chains.
Rollups should never feel magical to engineers. If a value changes, the system should explain why.
Implementing Field Values
For a flexible database, I would start with a field definition table and a record values table.
create table database_fields (
id uuid primary key default gen_random_uuid(),
database_id uuid not null references databases(id),
name text not null,
type text not null,
config jsonb not null default '{}',
position int not null,
created_at timestamptz not null default now()
);
create table database_cell_values (
record_id uuid not null references database_records(id),
field_id uuid not null references database_fields(id),
value jsonb not null,
updated_at timestamptz not null default now(),
primary key (record_id, field_id)
);
This is not the only model, but it is understandable. It supports custom fields without changing database columns every time a user adds a field.
The API should validate field values based on field type:
function validateCellValue(field: Field, value: unknown) {
switch (field.type) {
case "text":
return z.string().max(10_000).parse(value);
case "number":
return z.number().parse(value);
case "select":
return validateSelectOption(field.config.options, value);
case "relation":
return validateRelatedRecordIds(field.config.databaseId, value);
default:
throw new Error(`Unsupported field type: ${field.type}`);
}
}
Without this validation layer, a flexible database quickly becomes a pile of untrusted JSON.
Local State vs Persisted State
Canvas and database UIs need local state for responsiveness.
Examples:
- Dragging a node should not write to the database on every pixel.
- Inline editing should feel instant.
- Column resizing and view changes may be local until committed.
- Optimistic updates should be reversible.
Use a clear split:
- Local interaction state: drag positions, active selection, hover state, draft edits.
- Client cache state: current server-known canvas/table.
- Persisted state: saved nodes, records, fields, permissions.
When these layers blur, bugs become hard to debug.
Performance
Performance issues show up early in flexible products.
Canvas performance:
- Render only visible or relevant nodes when possible.
- Keep node components lightweight.
- Debounce position persistence.
- Avoid global re-renders on every drag.
- Store large embedded content outside the node payload.
Table performance:
- Use virtualization for large views.
- Paginate or window records.
- Avoid recalculating all rollups on every edit.
- Batch writes.
- Cache field metadata.
Realtime performance:
- Broadcast small patches, not full documents.
- Use resource-specific channels.
- Refetch snapshots after reconnect.
- Avoid sending every cursor movement through the same path as important data changes.
Realtime Sync
Realtime collaboration has different event types.
flowchart LR
UserAction[User Action] --> Local[Optimistic Local Update]
Local --> Persist[Persist Mutation]
Persist --> DB[(Database)]
DB --> Realtime[Realtime Event]
Realtime --> Peers[Other Clients]
Peers --> Reconcile[Reconcile State]
Useful event categories:
- Presence: who is viewing or editing.
- Ephemeral UI: cursors, selection, hover.
- Durable changes: node moved, record updated, field added.
- System changes: permission changed, record archived, view updated.
Do not treat all realtime events equally. Presence can be lossy. Durable changes cannot.
Undo, Redo, And Versioning
Undo/redo is deceptively difficult in collaborative products.
For a single-user canvas, undo can be local. For multi-user collaboration, undo must understand whether the thing being undone has changed since the action happened.
Options:
- Local-only undo for uncommitted changes.
- Operation-based undo for committed changes.
- Snapshot restore for larger version history.
- Manual version history for documents/canvases.
Operation-based undo is powerful but requires disciplined event modeling. For example, moving a node from (10, 10) to (40, 30) can be reversed if the node still exists and has not been moved by someone else in a conflicting way.
For v1 products, I prefer:
- Local undo for active editing.
- Debounced persistence.
- Version snapshots at meaningful save points.
- Explicit restore flow for older versions.
This avoids pretending the product has Google Docs-level conflict handling before it actually does.
Migration Strategy For User-Defined Schemas
User-defined fields create schema migration problems inside your product.
If a user changes a field from text to number, what happens to existing values? If a relation field is deleted, what happens to rollups depending on it? If a column is renamed, do formulas or automations break?
Minimum strategy:
- Field definitions have stable IDs independent of display names.
- Field type changes are validated.
- Destructive changes show impact.
- Dependent rollups/formulas are tracked.
- Old values are preserved when possible.
This is the difference between a toy database UI and a product people can trust with real work.
Permissions
Permissions should be designed before collaboration becomes broad.
Common layers:
- Workspace membership.
- Document access.
- Canvas access.
- Database access.
- Record-level restrictions if needed.
- Admin/editor/viewer roles.
Every API mutation should check permissions. Frontend checks are for experience, not security.
For realtime, users should only subscribe to channels they are allowed to access. If permissions change, active subscriptions may need to be revoked or refreshed.
Lessons Learned
Flexible products need constraints. Without constraints, every feature becomes a special case.
Important lessons:
- Keep domain schemas independent from UI libraries.
- Separate local interaction state from persisted data.
- Persist fewer writes during drag-heavy workflows.
- Design permissions early.
- Treat realtime as synchronization, not storage.
- Build admin/debug views for complex user-generated structures.
- Prefer simple primitives that compose well.
How This Solves The Product Problem
The architecture solves the original problem by making flexibility manageable.
The canvas has its own model instead of being trapped inside a UI library. The database has stable field and record primitives instead of ad hoc JSON everywhere. Realtime events are separated by durability. Permissions are checked at mutation and subscription boundaries. Performance is handled through virtualization, batching, and resource-specific updates.
That gives the product room to grow without turning every new feature into a rewrite.
A Practical Build Sequence
The safest way to build this kind of product is in layers.
Phase 1: stable primitives.
- Workspaces.
- Documents.
- Canvases.
- Tables.
- Fields.
- Records.
- Basic permissions.
Phase 2: interaction quality.
- Dragging and selection.
- Inline editing.
- Table views.
- Debounced persistence.
- Basic version snapshots.
Phase 3: collaboration.
- Presence.
- Resource-specific realtime channels.
- Durable change events.
- Reconnect recovery.
- Permission-aware subscriptions.
Phase 4: advanced flexibility.
- Relations.
- Rollups.
- Embeds.
- Templates.
- Automations.
- AI-assisted structure generation.
Trying to build all of this at once usually creates a product that looks impressive but is difficult to stabilize. Layering lets the team validate the primitives before adding features that depend on them.
Product Decisions That Affect Architecture
Several product decisions should be made early because they shape the system:
- Is a canvas reusable across documents or owned by one document?
- Can a database record exist without a table view?
- Are permissions inherited or overridden per resource?
- Are rollups expected to be real-time?
- Can users build automations on top of fields?
- Do public/shared views expose the same data model?
These are product questions, but they become architecture constraints. The more flexible the product promise, the more disciplined the underlying model must be.
Production Checklist
- Canvas nodes and edges have stable domain models.
- Drag updates are debounced or batched.
- Table views use virtualization or pagination.
- Field definitions are versioned or migration-aware.
- Durable realtime events are recoverable after reconnect.
- Presence events are separated from persisted changes.
- Permission checks exist on every mutation.
- Large embedded assets are stored outside primary records.
- Rollups and relations are computed predictably.
- Debug tooling can inspect workspace, canvas, table, and record state.
Closing Thought
A visual canvas and Notion-style database product is not just an ambitious frontend. It is a product platform.
The engineering challenge is to make flexible primitives feel simple to users while keeping the underlying system understandable to developers. That requires disciplined state boundaries, careful persistence, thoughtful permissions, and performance work from the beginning.