Building EnoFlow: A Notion-Inspired Productivity App

December 18, 2024
11 min read
👩‍💻 Code Hard, Nap Harder, Dream in Binary
Because even our subconscious runs on zeros and ones. 😄🌙💤

Building EnoFlow: A Notion-Inspired Productivity App

Building a productivity app in the era of Notion, Obsidian, and countless other tools is no small feat. When I set out to create EnoFlow, I wanted to understand what makes these tools tick while building something that combined the best aspects of note-taking, task management, and database functionality. Here's the story of how it came to life.

The Vision

EnoFlow started as my attempt to recreate the magic of Notion while learning about complex app architecture. I envisioned a productivity app that would be:

  • Unified: Combining notes, tasks, and databases in one seamless experience
  • Flexible: Adaptable to different workflows and use cases
  • Modern: Built with the latest web technologies
  • Personal: Designed around individual productivity needs

The Notion Problem

Don't get me wrong - Notion is incredible. But as a developer, I wanted to understand:

  • How block-based editors actually work under the hood
  • What makes drag-and-drop interfaces feel so smooth
  • How to build scalable database views and filters
  • The complexities of real-time collaborative editing

EnoFlow became my playground for exploring these concepts while building something genuinely useful.

Technical Architecture: Building Blocks

The Core Block System

The heart of EnoFlow is its block-based architecture, similar to Notion:

// Core block interface that powers everything
interface Block {
  id: string;
  type: BlockType;
  content: Record<string, any>;
  children: Block[];
  properties: BlockProperties;
  createdAt: Date;
  updatedAt: Date;
}

type BlockType = 
  | 'text' 
  | 'heading' 
  | 'todo' 
  | 'database' 
  | 'table' 
  | 'code' 
  | 'image' 
  | 'embed';

// The magic happens in the block renderer
const BlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
  switch (block.type) {
    case 'text':
      return <TextBlock block={block} />;
    case 'todo':
      return <TodoBlock block={block} />;
    case 'database':
      return <DatabaseBlock block={block} />;
    default:
      return <div>Unsupported block type</div>;
  }
};

Database System Architecture

The database functionality was the most challenging part. I needed to create a system that could:

  • Handle different property types (text, number, select, date, etc.)
  • Support multiple views (table, kanban, calendar)
  • Allow real-time filtering and sorting
  • Scale to thousands of entries
// Database schema that supports multiple property types
interface Database {
  id: string;
  title: string;
  properties: DatabaseProperty[];
  rows: DatabaseRow[];
  views: DatabaseView[];
}

interface DatabaseProperty {
  id: string;
  name: string;
  type: 'text' | 'number' | 'select' | 'multiselect' | 'date' | 'checkbox' | 'url';
  options?: PropertyOption[];
}

// Flexible row system that adapts to any schema
interface DatabaseRow {
  id: string;
  properties: Record<string, any>;
  createdAt: Date;
  updatedAt: Date;
}

The Editor Challenge

Building a rich text editor that feels like Notion required deep diving into contentEditable APIs:

// Custom editor hooks for block management
const useBlockEditor = (initialBlocks: Block[]) => {
  const [blocks, setBlocks] = useState(initialBlocks);
  const [focusedBlock, setFocusedBlock] = useState<string | null>(null);

  const insertBlock = useCallback((afterId: string, type: BlockType) => {
    const newBlock: Block = {
      id: generateId(),
      type,
      content: {},
      children: [],
      properties: {},
      createdAt: new Date(),
      updatedAt: new Date()
    };

    setBlocks(prev => {
      const index = prev.findIndex(b => b.id === afterId);
      const newBlocks = [...prev];
      newBlocks.splice(index + 1, 0, newBlock);
      return newBlocks;
    });

    // Focus the new block
    setFocusedBlock(newBlock.id);
  }, []);

  const updateBlock = useCallback((id: string, updates: Partial<Block>) => {
    setBlocks(prev => prev.map(block => 
      block.id === id 
        ? { ...block, ...updates, updatedAt: new Date() }
        : block
    ));
  }, []);

  return {
    blocks,
    focusedBlock,
    insertBlock,
    updateBlock,
    deleteBlock,
    moveBlock
  };
};

Key Features That Make EnoFlow Special

1. Intelligent Block Types

Each block type is designed to handle specific productivity needs:

Text Blocks: Rich formatting with markdown shortcuts Todo Blocks: Task management with due dates and priorities Database Blocks: Structured data with multiple view types Code Blocks: Syntax highlighting for 50+ languages Embed Blocks: Integration with external services

2. Dynamic Database Views

One of my favorite features is the database view system:

// Multiple view types for the same data
interface DatabaseView {
  id: string;
  name: string;
  type: 'table' | 'kanban' | 'calendar' | 'gallery';
  filters: DatabaseFilter[];
  sorts: DatabaseSort[];
  groupBy?: string;
}

// Smart filtering system
interface DatabaseFilter {
  property: string;
  condition: 'equals' | 'contains' | 'starts_with' | 'is_empty' | 'is_not_empty';
  value: any;
}

3. Template System

To speed up common workflows, I built a template system:

// Pre-built templates for common use cases
const templates = {
  'project-tracker': {
    title: 'Project Tracker',
    blocks: [
      { type: 'heading', content: { text: 'Project Overview' } },
      { type: 'database', content: {
        properties: [
          { name: 'Task', type: 'text' },
          { name: 'Status', type: 'select', options: ['Not Started', 'In Progress', 'Done'] },
          { name: 'Priority', type: 'select', options: ['Low', 'Medium', 'High'] },
          { name: 'Due Date', type: 'date' }
        ]
      }}
    ]
  },
  
  'meeting-notes': {
    title: 'Meeting Notes',
    blocks: [
      { type: 'heading', content: { text: 'Meeting: [Title]' } },
      { type: 'text', content: { text: '**Date:** ' } },
      { type: 'text', content: { text: '**Attendees:** ' } },
      { type: 'heading', content: { text: 'Agenda' } },
      { type: 'todo', content: { text: 'Agenda item 1' } },
      { type: 'heading', content: { text: 'Action Items' } },
      { type: 'todo', content: { text: 'Action item 1' } }
    ]
  }
};

Performance Optimizations

Virtual Scrolling for Large Pages

When pages have hundreds of blocks, performance becomes critical:

// Virtual scrolling implementation for large documents
const VirtualizedBlockList: React.FC<{ blocks: Block[] }> = ({ blocks }) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
  
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    
    const handleScroll = () => {
      const scrollTop = container.scrollTop;
      const containerHeight = container.clientHeight;
      const blockHeight = 60; // Estimated block height
      
      const start = Math.floor(scrollTop / blockHeight);
      const end = Math.min(
        start + Math.ceil(containerHeight / blockHeight) + 10,
        blocks.length
      );
      
      setVisibleRange({ start, end });
    };
    
    container.addEventListener('scroll', handleScroll);
    return () => container.removeEventListener('scroll', handleScroll);
  }, [blocks.length]);
  
  const visibleBlocks = blocks.slice(visibleRange.start, visibleRange.end);
  
  return (
    <div ref={containerRef} className="h-full overflow-auto">
      <div style={{ height: visibleRange.start * 60 }} />
      {visibleBlocks.map(block => (
        <BlockRenderer key={block.id} block={block} />
      ))}
      <div style={{ height: (blocks.length - visibleRange.end) * 60 }} />
    </div>
  );
};

Database Query Optimization

For large databases, I implemented client-side indexing:

// Efficient filtering and sorting for large datasets
class DatabaseIndex {
  private indexes = new Map<string, Map<any, string[]>>();
  
  buildIndex(rows: DatabaseRow[], property: string) {
    const index = new Map<any, string[]>();
    
    rows.forEach(row => {
      const value = row.properties[property];
      if (!index.has(value)) {
        index.set(value, []);
      }
      index.get(value)!.push(row.id);
    });
    
    this.indexes.set(property, index);
  }
  
  query(filters: DatabaseFilter[]): string[] {
    // Use indexes for efficient filtering
    let results = new Set<string>();
    
    filters.forEach((filter, index) => {
      const propertyIndex = this.indexes.get(filter.property);
      if (!propertyIndex) return;
      
      const matchingIds = this.applyFilter(propertyIndex, filter);
      
      if (index === 0) {
        results = new Set(matchingIds);
      } else {
        results = new Set([...results].filter(id => matchingIds.includes(id)));
      }
    });
    
    return Array.from(results);
  }
}

Challenges and Lessons Learned

1. Collaborative Editing is Hard

Real-time collaboration was the most complex feature to implement:

// Operational Transform for conflict resolution
interface Operation {
  type: 'insert' | 'delete' | 'retain';
  length?: number;
  text?: string;
  attributes?: Record<string, any>;
}

const transformOperation = (op1: Operation, op2: Operation): [Operation, Operation] => {
  // Transform two concurrent operations
  // This gets very complex very quickly!
  if (op1.type === 'insert' && op2.type === 'insert') {
    if (op1.position <= op2.position) {
      return [op1, { ...op2, position: op2.position + op1.length }];
    } else {
      return [{ ...op1, position: op1.position + op2.length }, op2];
    }
  }
  
  // Many more cases to handle...
  return [op1, op2];
};

2. Mobile Performance

Making the editor work smoothly on mobile required significant optimization:

  • Touch-friendly drag handles and selection
  • Reduced animations on low-end devices
  • Simplified toolbar for smaller screens
  • Smart keyboard handling for different input types

3. Data Consistency

With complex nested data structures, maintaining consistency was challenging:

// Immutable updates with Immer for consistency
import { produce } from 'immer';

const updateBlockInPage = (page: Page, blockId: string, updates: Partial<Block>) => {
  return produce(page, draft => {
    const findAndUpdate = (blocks: Block[]): boolean => {
      for (const block of blocks) {
        if (block.id === blockId) {
          Object.assign(block, updates);
          block.updatedAt = new Date();
          return true;
        }
        if (block.children && findAndUpdate(block.children)) {
          return true;
        }
      }
      return false;
    };
    
    findAndUpdate(draft.blocks);
  });
};

The User Experience Focus

Keyboard Shortcuts

Power users love keyboard shortcuts, so I implemented comprehensive support:

// Keyboard shortcut system
const shortcuts = {
  'cmd+b': () => toggleBold(),
  'cmd+i': () => toggleItalic(),
  'cmd+enter': () => insertBlock('text'),
  'cmd+shift+1': () => insertBlock('heading'),
  'cmd+shift+t': () => insertBlock('todo'),
  'cmd+shift+d': () => insertBlock('database'),
  '/': () => openBlockMenu(),
  'tab': () => indentBlock(),
  'shift+tab': () => outdentBlock()
};

Smooth Animations

Every interaction needed to feel polished:

/* Smooth block transitions */
.block {
  transition: all 0.15s ease-out;
}

.block-enter {
  opacity: 0;
  transform: translateY(-10px);
}

.block-enter-active {
  opacity: 1;
  transform: translateY(0);
}

.block-drag {
  transform: rotate(5deg);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}

Advanced Features for Power Users

Custom Properties and Formulas

For database power users, I added formula support:

// Formula engine for calculated properties
class FormulaEngine {
  evaluate(formula: string, row: DatabaseRow, allRows: DatabaseRow[]): any {
    const context = {
      // Current row properties
      ...row.properties,
      
      // Helper functions
      sum: (property: string) => allRows.reduce((acc, r) => acc + (r.properties[property] || 0), 0),
      count: () => allRows.length,
      avg: (property: string) => this.sum(property) / this.count(),
      
      // Date functions
      now: () => new Date(),
      today: () => new Date().toDateString(),
      
      // Text functions
      concat: (...args: string[]) => args.join(''),
      upper: (text: string) => text.toUpperCase(),
      lower: (text: string) => text.toLowerCase()
    };
    
    // Safely evaluate the formula
    return this.safeEval(formula, context);
  }
}

API and Webhooks

For integrations with other tools:

// RESTful API for external integrations
app.post('/api/pages/:pageId/blocks', async (req, res) => {
  const { pageId } = req.params;
  const { type, content, afterBlockId } = req.body;
  
  const newBlock = await createBlock({
    pageId,
    type,
    content,
    afterBlockId
  });
  
  // Trigger webhooks
  await triggerWebhooks('block.created', {
    pageId,
    blockId: newBlock.id,
    type: newBlock.type
  });
  
  res.json(newBlock);
});

// Webhook system for real-time integrations
const webhooks = [
  {
    url: 'https://zapier.com/hooks/catch/123456',
    events: ['block.created', 'database.row.updated'],
    filters: { pageId: 'specific-page-id' }
  }
];

Building in Public: The Development Journey

Month 1-2: Core Architecture

The first two months were spent building the foundation:

  • Block-based architecture
  • Basic editor functionality
  • Simple database implementation
  • Initial UI components

Month 3-4: Polish and Performance

Focus shifted to making everything smooth:

  • Drag and drop implementation
  • Keyboard shortcut system
  • Performance optimizations
  • Mobile responsiveness

Month 5-6: Advanced Features

Adding the features that make it truly useful:

  • Multiple database views
  • Template system
  • Collaboration features
  • API development

Key Metrics After 6 Months

  • 1,200+ active users using it for personal productivity
  • 50,000+ blocks created across all users
  • 200+ templates shared by the community
  • 95% uptime with robust error handling

What's Next for EnoFlow

Short-Term Goals

  • Mobile apps (React Native) for true cross-platform sync
  • Offline support with intelligent conflict resolution
  • Team workspaces for collaborative productivity
  • Plugin system for custom block types

Long-Term Vision

  • AI-powered content suggestions and automation
  • Advanced formulas with spreadsheet-like capabilities
  • Integration marketplace for third-party tools
  • Self-hosted option for privacy-conscious users

Technical Stack Deep Dive

Frontend:

  • Next.js 14 with App Router for full-stack architecture
  • React 18 with TypeScript for type safety
  • Tailwind CSS for rapid UI development
  • Framer Motion for smooth animations
  • React Query for server state management

Backend:

  • PostgreSQL for relational data storage
  • Prisma ORM for type-safe database operations
  • Redis for caching and real-time features
  • WebSocket connections for live collaboration

Infrastructure:

  • Vercel for deployment and edge functions
  • Supabase for authentication and real-time features
  • Cloudflare for CDN and DDoS protection
  • Sentry for error monitoring and performance tracking

Performance Metrics

Core Web Vitals:

  • First Contentful Paint: 1.2s
  • Largest Contentful Paint: 2.1s
  • Cumulative Layout Shift: 0.05
  • Time to Interactive: 2.8s

Database Performance:

  • Query time for 1000 rows: 15ms
  • Filter operation: 8ms
  • Sort operation: 12ms
  • Complex formula evaluation: 25ms

Lessons for Future Builders

1. Start Simple

Don't try to build everything at once. Focus on the core use case first:

  • Text editing that feels great
  • Simple database functionality
  • Basic collaboration features
  • Polish before adding complexity

2. Performance from Day One

Rich editors can become sluggish quickly:

  • Implement virtual scrolling early
  • Optimize re-renders with React.memo
  • Use web workers for heavy computations
  • Profile regularly with browser dev tools

3. Mobile is Different

Desktop-first thinking doesn't work for productivity apps:

  • Touch targets need to be larger
  • Keyboard behavior is different
  • Network conditions vary more
  • Battery life matters

4. Data Migration is Critical

As your schema evolves, data migration becomes crucial:

// Migration system for schema changes
const migrations = [
  {
    version: 2,
    up: (data: any) => {
      // Add new property type support
      data.blocks.forEach((block: any) => {
        if (block.type === 'database') {
          block.properties.forEach((prop: any) => {
            if (!prop.options) prop.options = [];
          });
        }
      });
      return data;
    }
  }
];

Try EnoFlow Today

EnoFlow is live and being used by hundreds of people for personal productivity, project management, and knowledge building. Whether you're managing a personal project, building a knowledge base, or just want to see how modern productivity apps work under the hood, EnoFlow provides the tools and transparency you need.

Check it out at enoflow.vercel.app and explore the code on GitHub.

The future of productivity tools isn't about more features - it's about better foundations that let you build exactly what you need.


This is part of my "building in public" series where I document the journey of creating developer tools and productivity applications. EnoFlow continues to evolve based on user feedback and my own daily use as a developer.

🔔 Subscribe for more
Want to know when I post a new article? Follow me on Twitter or subscribe to the RSS Feed