Build a Real-Time Excalidraw-like Collaborative Canvas using Velt MCP and Antigravity🎉

Velt landing page

In this tutorial, we’ll build an Excalidraw-style collaborative whiteboard using Next.js, HTML5 Canvas, and Velt. You’ll add real-time features like live cursors, comments, presence, and huddles directly into your app. Instead of wiring everything manually, we’ll use Velt MCP and AI agents to handle the integration. We’ll also look at how CRDT-based sync keeps everything in real time.

By the end, you’ll have a fully working multi-user canvas app with production-ready collaboration built in.

What we are building

  • Excalidraw-style infinite whiteboard
  • Real-time collaboration with cursors, comments, huddle, notifications, and presence
  • Multi-user canvas with shared state

Why add collaboration to Canvas apps

  • Single-user by default: Most canvas apps work locally and don’t support multiple users out of the box
  • Real-time sync is complex: Handling state sync, conflicts, and updates across users is not trivial
  • Lack of shared context: Without comments, cursors, and presence, collaboration feels disconnected

Why use Velt

Velt is a collaboration SDK that lets you add real-time, multi-user features directly into your app without building the backend infrastructure yourself. It handles presence, syncing, communication, and UI components out of the box, so you can focus on your product.

  • Drop-in collaboration layer: Add features like comments, cursors, and presence without building from scratch
  • Real-time features built in: Cursors, comments, presence, notifications, and huddles
  • CRDT-based sync support: Enables conflict-free real-time state updates for multi-user apps
  • AI-powered setup with MCP: Use Velt MCP and agent skills to automatically install and configure features
  • No infra needed: No need to manage WebSockets, sync engines, or backend services
  • Customizable UI components: Easily integrate collaboration UI into your existing design system

Velt landing page

Prerequisites

  • Node.js 18+
  • Velt API key (from Velt dashboard)
  • AI coding editor (Anitgravity is used in this demo)
  • Basic React and TypeScript knowledge

Setting up the project

App canvas

Tutorial: Building Velt-powered Excalidraw-like App

Step 1: Set up Velt MCP

Velt MCP lets your editor (Antigravity) run the Velt installer and guide the integration.

Now, add the Velt MCP installer to Antigravity using the command below:

npx -y @velt-js/mcp-installer

MCP installation

Add it to the Antigravity MCP server configuration with command: "npx" and args: ["-y", "@velt-js/mcp-installer"].

Also, Velt Agent Skills guides the AI on what to implement using best practices, while MCP gives it access to the tools needed to actually execute those changes. Together, they make the integration accurate, structured, and reliable. We have both installed and will be used accordingly.

Get your Velt API key:

  • Go to the Velt Dashboard
  • Create a project
  • Copy your API key

Add it to your .env:

NEXT_PUBLIC_VELT_API_KEY=your_api_key_here

Step 2: Start Velt installation using AI

Now that MCP is set up, we can let the AI agent handle the Velt integration for us.

Open your editor (Antigravity) and type:

install velt

This triggers the Velt MCP installer, which runs as a guided setup inside your editor.

Instead of manually adding SDKs and wiring things, the agent walks you through the setup step by step.

It will ask you for a few inputs:

  • Your project directory
  • Your Velt API key and auth token
  • The features you want to enable (comments, presence, cursors, CRDT, etc.)
  • Where to place the VeltProvider (recommended: app/page.tsx)
  • UI placement preferences (like corner position)

You can answer each step directly in chat. The flow is simple and guided.

Step 3: Provide the prompt for MCP

At this point, we already have a working whiteboard built manually. Now, instead of integrating Velt step by step ourselves, we use Velt Agent Skills to analyze this existing app and plan how collaboration should be added.

In your editor, after running install velt, provide the following prompt:

I want to start the Velt integration. Review my project structure and use your Velt Agent Skills to plan the CRDT store implementation.

Once you provide this, the agent starts analyzing your codebase. It looks at how your canvas is structured, how state is managed, and where real-time sync can be introduced. Based on this, it generates an integration plan tailored to your whiteboard.

Instead of manually deciding how to structure CRDT or where to wire Velt, the agent uses its skills to plan it correctly for your app.

After reviewing the plan, you can approve it, and the agent will apply the changes step by step.

Planing with agent

Step 4: Understand the existing project structure

Before we look at what MCP added, let us understand how this project is structured. Since the agent analyzes your codebase before integrating Velt, this gives context for what it is working with.

The project is organized into three main folders: app, components, and lib.

  • The app/ folder contains the core application logic. This is where the whiteboard is rendered and all canvas interactions like drawing, selecting, and updating elements are handled.
  • The components/ folder contains UI elements and collaboration integrations. This is where Velt features are connected to your app, including user identity, comments, and UI-level controls.
  • The lib/ folder handles state management and shared logic. It manages canvas data, document context, and sync-ready state, making it easier to extend the app with real-time collaboration.

Step 5: Add real-time collaboration features (Using MCP)

Now that the whiteboard is working, we layer Velt on top to make it collaborative. This is where users start seeing each other, interacting in real time, and sharing context.

After you provide the prompt and approve the plan, the MCP installer integrates Velt into your project. It sets up the foundation required for collaboration to work correctly with your existing whiteboard.

Presence and cursors

In app/layout.tsx, the VeltProvider enables real-time awareness across your app. Then in VeltSetup.tsx, each user is identified using. This is what allows Velt to track who is online.

Learn more here

'use client'
import React, { useEffect, useState, Suspense } from "react";
import {
  VeltProvider,
  useSetDocument,
  VeltCursor,
  useVeltClient,
} from "@veltdev/react";
import { useCurrentDocument } from "@/lib/useCurrentDocument";
import { TEST_USERS } from "@/lib/users";
import { useSearchParams } from "next/navigation";

function VeltIdentity({ children }: { children: React.ReactNode }) {
  const { documentId } = useCurrentDocument();
  useSetDocument(documentId ?? "default-whiteboard");
  return <>{children}</>;
}

function VeltProviderInner({ children }: { children: React.ReactNode }) {
  const searchParams = useSearchParams();
  const [user, setUser] = useState(TEST_USERS[0]);

  useEffect(() => {
    const userIndex = searchParams.get("user");
    if (userIndex) {
      const index = parseInt(userIndex);
      if (!isNaN(index) && TEST_USERS[index]) {
        setUser(TEST_USERS[index]);
      }
    } else {
      // Fallback or default behavior
    }
  }, [searchParams]);

  return (
    <VeltProvider
      apiKey={process.env.NEXT_PUBLIC_VELT_API_KEY!}
      authProvider={{
        user: user,
      }}
    >
      <VeltIdentity>
        {/* <VeltCursor /> */}
        {children}
      </VeltIdentity>
    </VeltProvider>
  );
}

export function VeltSetup({ children }: { children: React.ReactNode }) {
  return (
    <Suspense fallback={null}>
      <VeltProviderInner>{children}</VeltProviderInner>
    </Suspense>
  );
}

Once identity is set, Velt automatically shows:

  • Active users (avatars)
  • Live cursor positions

You don’t have to manually sync cursor movement. Velt handles that internally based on user sessions.

Canvas comments

Comments are one of the most important parts of a canvas app.

"use client";

import { useCommentAnnotations, VeltCommentPin } from "@veltdev/react";
import { Point } from "@/lib/types";
import { useWhiteboardStore } from "@/lib/useWhiteboardStore";

interface CanvasCommentLayerProps {
  zoom: number;
  pan: Point;
}

In CanvasCommentLayer.tsx, comments are rendered as an overlay on top of the canvas. Instead of attaching comments to DOM elements, we attach them to canvas coordinates.

 {
              // Use bounds or specific fields
              // Normalized rect provided by normalizeRect helper? not available here easily.
              // Just use raw coords if available, or approximate.
              // Rect/Ellipse/Diamond have x1,y1,x2,y2 usually?
              // Wait, types.ts says DrawingElement...
              // Let's assume standard shape properties
              if (
                "x1" in element &&
                "y1" in element &&
                "x2" in element &&
                "y2" in element
              ) {
                worldX = (element.x1 + element.x2) / 2;
                worldY = (element.y1 + element.y2) / 2;
              }
            } else if (element.type === "line" || element.type === "arrow") {
              worldX = (element.x1 + element.x2) / 2;
              worldY = (element.y1 + element.y2) / 2;
            }
          }
        }

        if (typeof worldX !== "number" || typeof worldY !== "number") {
          return null;
        }

        const screenX = (worldX + pan.x) * zoom;
        const screenY = (worldY + pan.y) * zoom;

        return (
          <div
            key={annotation.annotationId}
            style={{
              position: "absolute",
              left: `${screenX}px`,
              top: `${screenY}px`,
              transform: "translate(-50%, -100%)",
              zIndex: 50,
              pointerEvents: "auto",
            }}
          >
            <VeltCommentPin annotationId={annotation.annotationId} />
          </div>
        );
      })}
    </div>
  );

App canvas with comment

From app[/page.tsx](https://github.com/Studio1HQ/Velt-Demos/blob/main/excalidraw-velt-demo/app/page.tsx), you trigger comments like this:

  • Capture the (x, y) position on click
  • Pass context to Velt
  • Optionally attach to a specific element using elementId

This enables:

  • Freeform comments anywhere on the canvas
  • Context-aware discussions linked to shapes

This is very similar to how tools like Miro or Figma handle comments.

Sidebar and UI controls

These files handle user-facing UI around collaboration.

  • ProfileMenu.tsx shows user identity and active participants
  • ThemeToggle.tsx syncs your app theme with Velt UI

ProfileMenu.tsx

"use client";

import React, { useEffect, useState } from "react";
import { ChevronDown, User, Check, LogOut, RefreshCwIcon } from "lucide-react";
// import { useVeltClient } from "@veltdev/react";
import { TEST_USERS } from "@/lib/users";

export function ProfileMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const [currentUser, setCurrentUser] = useState(TEST_USERS[0]); // Default to first user

  // Initialize from URL on mount
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const userIndex = params.get("user");
    if (userIndex) {
      const index = parseInt(userIndex);
      if (!isNaN(index) && TEST_USERS[index]) {
        setCurrentUser(TEST_USERS[index]);
      }
    }
  }, []);

  const handleUserSelect = (user: (typeof TEST_USERS)[0], index: number) => {
    setCurrentUser(user);
    setIsOpen(false);

    // Update URL and reload to ensure clean session isolation via VeltProvider authProvider
    const url = new URL(window.location.href);
    url.searchParams.set("user", index.toString());
    window.location.href = url.toString();
  };

Velt components (like comments sidebar) automatically adapt to your app’s theme. This keeps the experience consistent across your UI and Velt overlays.

Notifications and huddle

Velt also provides built-in tools for:

Velt in action on app

You can add these as UI buttons in your toolbar. Once added:

  • Notifications show real-time updates (comments, mentions, etc.)
  • Huddle lets users start live audio/video sessions inside your app

Velt huddles

No extra backend setup is needed. These features are already part of the Velt SDK.

You can see this here in page.tsx

 <div className="mx-1 h-6 w-px bg-slate-200 dark:bg-neutral-800" />
        <div className="flex items-center gap-1">
          <VeltCommentTool darkMode={isDark} />
          <VeltHuddleTool darkMode={isDark} />
          <VeltNotificationsTool darkMode={isDark} />
        </div>
      </section>

Step 6: CRDT-based state sync

Up to this point, the whiteboard works, and collaboration features like comments and presence are in place. But for a real collaborative canvas, the most important piece is shared state. Every shape, line, or text element needs to stay in sync across all users in real time.

This is where CRDT comes in.

In a typical canvas app, state lives locally. When a user draws something, it updates only their view. To make this collaborative, we need a shared state that all users can read and write to.

In useWhiteboardStore.ts, instead of using local state, we use Velt’s CRDT hook:

This creates a shared store that is automatically synced across all connected users.

The id acts as a unique identifier for this piece of state. As long as users are in the same document, they are all connected to this store. The map structure is used to store canvas elements, where each element is indexed by its ID.

"use client";

import { useCallback, useEffect, useMemo, useRef } from "react";
import { useVeltCrdtStore } from "@veltdev/crdt-react";
import type { DrawingElement } from "./types";

type ElementMap = Record<string, DrawingElement>;

export function useWhiteboardStore() {
  const { value, update } = useVeltCrdtStore<ElementMap>({
    id: "whiteboard-elements",
    type: "map",
    initialValue: {},
  });

  const elements: DrawingElement[] = useMemo(
    () => Object.values(value ?? {}).sort((a, b) => a.id.localeCompare(b.id)),
    [value],
  );

  const valueRef = useRef<ElementMap>(value ?? {});

  useEffect(() => {
    valueRef.current = value ?? {};
  }, [value]);

When a user draws a new shape or updates an existing one, the change is not stored locally. Instead, it is written to the CRDT store.

Functions like adding or updating elements internally call the update function provided by useVeltCrdtStore. Once updated, the change is automatically propagated to every other user connected to the same session.

const addElement = useCallback(
    (el: DrawingElement) => {
      update({ ...valueRef.current, [el.id]: el });
    },
    [update],
  );

  const updateElement = useCallback(
    (el: DrawingElement) => {
      update({ ...valueRef.current, [el.id]: el });
    },
    [update],
  );

  const deleteElement = useCallback(
    (id: string) => {
      const next = { ...valueRef.current };
      delete next[id];
      update(next);
    },
    [update],
  );

  return { elements, addElement, updateElement, deleteElement, rawMap: value };

There is no need to manage WebSockets, events, or manual syncing. Velt handles all of that behind the scenes.

The storageProxy.ts file acts as a thin abstraction layer between your canvas logic and the shared store. Instead of directly interacting with the CRDT store everywhere, this layer helps keep the code clean and organized.

It separates:

  • Canvas logic
  • State update logic

This makes the system easier to maintain and extend.

"use client";

/**
 * Validates if the code is running in a browser environment
 */
const isBrowser = typeof window !== "undefined";

/**
 * Proxies localStorage to redirect specific keys to sessionStorage
 * This allows Velt to have isolated sessions per tab (using sessionStorage)
 * while the rest of the app continues to use localStorage.
 */
export function initStorageProxy() {
  if (!isBrowser) return;

  // Store the original localStorage implementation
  const originalLocalStorage = window.localStorage;
  const originalSessionStorage = window.sessionStorage;

  // Keys that should be redirected to sessionStorage
  // Velt uses keys starting with 'velt', 'snippyly', or '_v' (e.g., _viu, _vv)
  // Also proxying firebase keys as Velt likely uses them for auth/presence
  const isVeltKey = (key: string) => {
    const k = key.toLowerCase();
    return (
      k.startsWith("velt") ||
      k.startsWith("snippyly") ||
      k.startsWith("_v") ||
      k.startsWith("firebase")
    );
  };

Step 7: Multi-user simulation

The user setup lives in lib/users.ts. This file defines a small set of users with basic details like name, color, and avatar. These are used by Velt to represent each participant across features like cursors, comments, and presence.

In components/velt/VeltSetup.tsx, one of these users is selected when the app loads. A random user is picked and passed to Velt. This is what establishes the session.

export const TEST_USERS = [
  {
    userId: "user1",
    name: "Robin",
    email: "[email protected]",
    photoUrl:
      "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8cG9ydHJhaXR8ZW58MHx8MHx8fDA%3D",
    color: "#F97316", // Orange
    organizationId: "excalidraw-demo",
  },
  {
    userId: "user2",
    name: "Alicia",
    email: "[email protected]",
    photoUrl:
      "https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?q=80&w=1364&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    color: "#3B82F6", // Blue
    organizationId: "excalidraw-demo",
  },
];

Every time the app loads, a different user can be assigned. From Velt’s perspective, each session is now a unique participant in the same document.

User switch from canvas UI

Step 8: Run and test the app

Now let’s run the app and see everything working together.

Start the development server:

npm run dev

Once the app is running, open it in your browser at http://localhost:3000.

To test collaboration, open the same app in another window. You can use an incognito tab or a different browser. Each session will act as a different user.

Side by side comparison of app functionality

As you interact with the canvas, you’ll start noticing the real-time behavior. Drawing a shape in one window reflects instantly in the other. You’ll see users appearing in presence, and comments syncing across both sessions.

At this point, your whiteboard is fully collaborative, with multiple users interacting on the same canvas in real time.

Multiple users interacting with our app in real-time

Here is the deployed link for you to explore the demo.

Wrapping up

We started with a simple canvas and turned it into a fully collaborative whiteboard. Along the way, we added real-time cursors, presence, comments, notifications, and even voice huddles using Velt. Instead of building sync logic and infra from scratch, we used Velt MCP and agent skills to handle the heavy lifting and get everything working quickly.

The key takeaway here is simple. You don’t need to spend weeks building real-time systems to make your app collaborative. With the right tools, you can focus on your product and let the platform handle sync, presence, and communication.

From here, you can extend this further:

  • Connect real authentication instead of simulated users
  • Improve CRDT logic for more complex canvas operations
  • Customize Velt UI components to match your product

If you’re building any product where users need to collaborate, this is a pattern worth exploring.

What would your app look like if it supported real-time collaboration from day one?

Try Velt and start adding features like comments, presence, cursors, CRDT sync, notifications, and huddles to your app.

Using Reddit to Validate SaaS Ideas Before Building

One quiet way to waste months on a SaaS product is to build before you understand the user’s language.

Reddit can help here when the work starts with research instead of posting.

For RedditFind, we analyzed SaaS-related subreddits by community fit, weekly activity, rules, posting risk, and representative posts.

Start with the right communities

Community Fit Weekly visitors Weekly contributions Best use
r/SaaS 98% 298K 18K SaaS founder stories, MRR, pricing, validation
r/microsaas 93% 74K 12K Micro SaaS, early revenue, first users
r/micro_saas 90% 36K 7K SEO, directories, Reddit growth, long-form playbooks
r/SideProject 87% 361K 17K Product demos, launch stories, feedback
r/startups 84% 137K 4.8K Startup methodology and strategy

Size is a weak default sort. A 36K weekly visitor community with specific micro SaaS threads can beat a much larger, noisier community when you are trying to understand first users or pricing.

Search for validation signals

Useful query patterns:

"SaaS" AND "nobody uses"
"validate my idea" OR "idea validation"
"first paying user" OR "first customer"
"alternative to" AND [competitor]
"[competitor] pricing" OR "[competitor] too expensive"
"looking for a tool" OR "does anyone know a tool"

These phrases sit close to useful moments: failed validation, replacement demand, pricing complaints, and people actively looking for a tool.

Separate launch communities from research communities

Some communities regularly accept feedback or launch-style posts:

  • r/SideProject
  • r/alphaandbetausers
  • r/nocode
  • r/indiehackers
  • r/microsaas

Other communities are stronger for reading and commenting:

  • r/SaaS
  • r/startups
  • r/Entrepreneur
  • r/b2bmarketing
  • r/sales
  • r/ProductManagement
  • r/GrowthHacking

The second group can still be the better source. You may never post a product link there, yet you can learn how buyers and builders describe the problem.

Capture reusable language

When reading threads, save:

  • Exact problem statements
  • Workarounds people use today
  • Competitor names and complaints
  • Pricing objections
  • Onboarding confusion
  • Sales objections
  • Retention problems
  • Phrases that show buying intent

This language can improve landing pages, feature naming, onboarding copy, email sequences, and support docs.

Participate before promoting

Basic safety rules:

  • Do not mass-message people.
  • Do not use fake users.
  • Do not hide your relationship to the product.
  • Do not copy the same post into many communities.
  • Do not force a product link into strict communities.

A safer reply pattern:

  • Answer the user’s actual question.
  • Add a detail from your own research or experience.
  • Disclose your relationship when it matters.
  • Mention the product only when it clearly fits the thread.

Many good Reddit replies do not include a link at all.

Full RedditFind research:

https://redditfind.ai/en/research/best-subreddits-for-saas-founders/

How We Built an AI That Evolves Alongside a Creator Through Memory

Let me tell you about the moment I knew we had a problem. We’d just shipped our content repurposing tool. A fitness YouTuber pasted in a video URL. Out came a LinkedIn post that opened with “In today’s fast-paced digital landscape…” The man deadlifts 200kg for a living. That’s when we decided our AI needed to actually learn who it was writing for, not just parrot generic marketing speak into a different shaped box.

Most AI tools for content creators work like a photocopier with a thesaurus. You paste text in, pick a platform, and out comes something that sounds like it was written by a committee of people who’ve never watched a YouTube video. We wanted something different. We wanted an AI that gets better at sounding like you the more you use it. Not because we fine-tuned a model (we don’t have that kind of GPU budget, and frankly, neither do you), but because the system actually remembers what you do and why.

This is the story of how we built that system, what broke along the way (spoiler: a lot), and why the combination of Hindsight’s agent memory and cascadeflow’s multi-model orchestration turned out to be the two pieces we didn’t know we needed until 3 AM on a Tuesday when everything else had failed.

What the System Actually Does

The elevator pitch: you give our system a YouTube URL. It downloads the video, transcribes it, finds the strongest 30 to 90 second moments, and generates production-ready briefs for Instagram Reels, YouTube Shorts, and LinkedIn. Each brief includes a hook, a full spoken script with editing cues like [CUT] and [PAUSE], a platform-native caption, and even a visual prompt for AI-generated b-roll. Basically, it does what a $5,000/month content team does, except it doesn’t take Fridays off.

But here’s the thing. Plenty of tools can chop a video into clips. That’s table stakes. The interesting part is what happens after the creator starts reviewing those briefs. Every edit, every approval, every rejection, every “regenerate this but make it less corporate” gets silently observed and stored as a memory. The next time the system generates content, it recalls those memories and adjusts.

The creator never fills out a settings page and checks boxes next to adjectives like “casual” and “bold.” (We all know nobody reads those forms honestly anyway. Everyone thinks they’re “authentic and relatable.”) The system just watches what they actually do and converges on their personality over time.

Think of it like hiring a new editor. Day one, they’re guessing. They write your hooks like a BuzzFeed intern from 2014. By week three, they know you hate exclamation marks, you always cut filler words, and your hooks work best when they open with a question, not a command. That’s the loop we built. Except this editor doesn’t ask for a raise and doesn’t have opinions about your Slack status.

What you’re looking at above: The pipeline flows left to right, from a YouTube URL through seven stages (Ingest, Transcribe, Recall, Extract, Clip, Generate, Review). The key detail is the feedback loop at the bottom, the red arrow that makes the whole thing worth building. Every editing action in the Review stage feeds observations back into Hindsight’s memory bank. On the next pipeline run, the Recall stage pulls those memories and injects them into both moment extraction and content generation. The system literally gets smarter with each review cycle. It’s like git for personality, except you never have to write a commit message.

The Memory Loop: Where Hindsight Fits

Here’s a confession. The first version of our system had no memory at all. Zero. Zilch. The kind of amnesia that makes Memento look like a documentary about a guy with excellent recall. It generated the same generic hooks regardless of who was using it. A fitness creator and a fintech newsletter writer got output in the same tone. That’s obviously wrong, but the fix isn’t obvious.

The naive approach: build a preferences form. Let the creator pick “casual” or “professional,” choose their platforms, list words to avoid. We built that too (it helps with the cold start problem, and honestly it makes the onboarding screen look impressive in screenshots). But it turns out people are terrible at describing their own style. A creator will tell you “I’m casual and direct” and then consistently reject every draft that doesn’t open with a specific data point. Ask anyone what kind of music they like. Now watch what they actually play in the car. Two completely different playlists.

Their stated preferences and their revealed preferences are two different universes. We needed to observe the second one.

That’s where Hindsight enters the picture. Instead of asking creators to describe themselves (an exercise roughly as accurate as asking a cat to describe its relationship with furniture), we observe what they do and store those observations as memories.


The loop in plain English: The creator reviews drafts and edits/approves/rejects them. Each action fires retain_diff_observation(), which stores a tagged observation in Hindsight’s memory bank. On the next pipeline run, recall() fetches relevant memories and reflect() synthesizes them into a compact paragraph. That paragraph gets injected into Claude’s generation prompt. The drafts come back better. The creator edits less. The loop tightens. Rinse and repeat until the AI sounds more like the creator than the creator does on a Monday morning.

Here’s the function that fires every time someone saves an edited draft. It’s not clever. It doesn’t need to be:

async def retain_diff_observation(
    before: str, after: str, platform: str, content_type: str
) -> None:
    if not before or not after or before.strip() == after.strip():
        return

    before_words = before.split()
    after_words  = after.split()
    delta        = len(after_words) - len(before_words)
    pct          = abs(delta) / max(len(before_words), 1) * 100

    if delta < -10 or pct > 30:
        action = "significantly shortened"
    elif delta < 0:
        action = "trimmed"
    elif delta > 10 or pct > 30:
        action = "significantly expanded"
    else:
        action = "rewrote (same length)"

    observation = (
        f"Creator {action} a {platform} {content_type} draft. "
        f'Before ({len(before_words)} words): "{before[:160]}" → '
        f'After ({len(after_words)} words): "{after[:160]}"'
    )

    await retain_observation(
        observation,
        tags=["editing-behaviour", "draft-edit", platform, content_type],
    )

Nothing fancy happening here. No PhD required. We compute a rough diff, classify whether it was a trim, expansion, or rewrite, and store a human-readable observation via Hindsight’s retain API. The tags are important, though. They let us query memories by type later without building our own taxonomy from scratch. (We tried building our own taxonomy once. It lasted two days before we set it on fire.)

The real magic shows up on the next pipeline run. Before we generate any content, we call two functions that sound like they belong in a therapist’s office:

recall_result = await recall_memories(
    query="How does this creator prefer their content? "
          "Hook styles, editing preferences."
)
reflection = await reflect_on_creator(
    query="Summarise this creator's content preferences, "
          "voice, and style."
)

recall fetches the raw observations. reflect synthesizes them into a compact paragraph, like a friend who can summarize your entire personality in three sentences (and is somehow right about all of them). That paragraph gets injected straight into the generation prompt as a ## Creator Voice & Preferences section. Claude doesn’t need fine-tuning. It just needs good context, and Hindsight provides exactly that.

The result is genuinely satisfying to watch. After three or four review sessions, the system stops generating hooks with exclamation marks for creators who always delete them. It starts opening LinkedIn posts with data points for creators who approve those. It learns that one creator shortens every tweet to under 120 characters and begins generating tighter drafts automatically. No retraining, no config files, no “please describe your brand voice in 500 words” forms. Just memory doing what memory does.

Keeping It Cheap: Where cascadeflow Fits

Here’s a problem we didn’t anticipate, which in retrospect we absolutely should have. After a creator completes a review session, we need to analyze all their editing events and extract style observations. That’s an LLM call. We also use LLM calls during moment extraction from transcripts, and for the actual brief generation. Each project can easily rack up 15 to 20 LLM calls. Our moment extraction needs to be accurate (you can’t miss the best 45 seconds of a 30-minute video, that’s literally the whole point), but our synthesis calls are simple text classification that a slightly motivated intern could do.

Using the same expensive model for everything felt like hiring a Michelin-star chef to make toast. Sure, the toast would be excellent. But your budget would be gone by Tuesday. That’s where cascadeflow saved us real money and possibly our sanity.

cascadeflow lets you define a drafter model (fast and cheap) and a verifier model (slower and accurate), and it routes each call to whichever model meets a quality threshold you set. The key insight is that different parts of the pipeline have different accuracy requirements:

def get_extraction_agent() -> CascadeAgent:
    """Higher quality: moment extraction needs accuracy."""
    return build_agent(quality_threshold=0.8)

def get_generation_agent() -> CascadeAgent:
    """Lower threshold: drafter handles most generation cheaply."""
    return build_agent(quality_threshold=0.65)

def get_synthesis_agent() -> CascadeAgent:
    """Synthesis uses drafter only: observations are simple text."""
    return build_agent(quality_threshold=0.5)

Three agents, three thresholds, same two underlying models. It’s like having a junior dev and a senior dev, and only paging the senior when the junior says “I’m not sure about this one.” The synthesis agent (which extracts observations like “creator always removes filler words”) runs on the drafter almost exclusively because the task is straightforward. The extraction agent, which needs to identify the strongest 30-second moments from a transcript, escalates to the verifier more often because getting that wrong means the whole project is useless. We didn’t have to think about routing logic or write a single if/else. We just set a quality number and cascadeflow handles the rest.

We also built a CostAccumulator that tracks every call and computes what we would have spent if everything went to the expensive model. Think of it as the “what if we hadn’t been smart about this” meter:

@dataclass
class CostAccumulator:
    total_calls: int = 0
    drafter_calls: int = 0
    verifier_calls: int = 0
    total_cost_usd: float = 0.0

    def record(self, result: Any) -> None:
        self.total_calls += 1
        model_used = getattr(result, "model_used", "") or ""
        cost = getattr(result, "total_cost", 0.0) or 0.0
        self.total_cost_usd += cost
        if settings.cascade_drafter_model in model_used:
            self.drafter_calls += 1
        else:
            self.verifier_calls += 1

This surfaces in the UI as a cost breakdown per project. Creators don’t care about it (they shouldn’t have to), but we stare at it like it’s a stock ticker. It tells us that roughly 70 to 80 percent of calls get handled by the drafter, which means cascadeflow is doing exactly what we hoped: using the expensive model only when it actually matters. The other 20 to 30 percent? Those are the calls where quality genuinely required the bigger model. We sleep better knowing we’re not burning money on tasks a smaller model handles perfectly.

The Seven-Stage Pipeline

The full pipeline is a background task triggered when a creator submits a YouTube URL. Each stage updates the project status in Supabase so the frontend can show a live progress stream (because nothing says “your money’s being well spent” like a progress bar that actually moves). Here’s the sequence:

  1. Ingest: Detect input type, try YouTube auto-captions first (faster than transcription).
  2. Transcribe: If no captions exist, download audio via yt-dlp and run Groq Whisper. Files over 25 MB get automatically chunked.
  3. Recall: Pull creator memory from Hindsight. If no memories exist yet (cold start), the system falls back to universal heuristics.
  4. Extract: Claude Haiku 4.5 analyzes the transcript in parallel chunks and identifies the 3 to 5 strongest moments. Each moment is scored on hook quality, narrative arc, and standalone clarity.
  5. Clip: yt-dlp downloads just the relevant segments (no full-video download), ffmpeg crops to 9:16 vertical. All clips extracted in parallel.
  6. Generate: Claude Sonnet 4.5 produces a full production brief per moment per platform, with the creator’s memory reflection injected into every prompt.
  7. Review: The creator edits, approves, or rejects. Every action feeds back into Hindsight. The loop closes.

The pipeline runs entirely as an async background task. If clip extraction fails for a particular moment (network issues, YouTube deciding it doesn’t like you today), it falls back to embedding a YouTube player with start/end timestamps. The frontend never shows a broken state. This is important. Nothing kills user trust faster than a loading spinner that never stops.

The Intelligence Graph: Making Memory Visible

Here’s something we learned the hard way: if the system is learning from you, you have to let the user see what it learned. Nobody trusts a black box. Especially not creators who’ve spent years building a personal brand and have strong opinions about whether they’re “witty” or “sarcastic” (it’s always sarcastic, by the way).

So we built a knowledge graph visualization. It runs three parallel Hindsight recall queries across different memory domains (style/tone, editing behavior, profile preferences), deduplicates by text, and classifies each memory into a node type using the tags that Hindsight already stores:

def _classify_memory_with_tags(text: str, tags: list[str]) -> tuple[str, str, float]:
    """Classify using real Hindsight tags first, fall back to keyword heuristic."""
    for tag in (tags or []):
        kind = _TAG_KIND.get(tag.lower().strip())
        if kind:
            return kind, _short_label(text), 0.80
    return _classify_memory(text)

Tags from Hindsight act as a first-class classification signal. We only fall back to keyword heuristics when tags are missing (which is like using a map when your GPS dies, except the map is made of regex and tears). The graph renders with five node types: root (the creator identity), traits (tone, style), platforms, preferences (editing patterns), and topics (niche). Edges encode semantic similarity, temporal proximity, and causal relationships (platform preferences causing editing patterns).

The creator can hover over any node and see the full observation text. It’s the difference between “the AI is learning” and “here’s exactly what the AI thinks it knows about you, and you can see it evolving in real time.” One tester told us it felt like reading their own therapy notes. We’re choosing to take that as a compliment.

Lessons Learned (a.k.a. Things We Wish Someone Had Told Us)

Memory is more useful than configuration. We have a preferences page. Creators fill it out during onboarding. But the observations extracted from actual editing behaviour are consistently more specific and more accurate than what people self-report. “I prefer casual tone” is less useful than “Creator consistently removes the word ‘essentially’ and shortens hooks to under 8 words.” If you’re building a personalization system, observe behavior first and ask questions second. People don’t know what they want until they see what they don’t want.

Quality thresholds beat manual routing. We initially wrote if task_type == "synthesis": use_cheap_model() branching logic. It was ugly. It was fragile. It was the kind of code that makes future-you send angry Slack messages to past-you. Replacing that with cascadeflow’s quality threshold was simpler and more robust. The threshold is a single number, and the system figures out when to escalate. We spent less time debugging routing decisions and more time tuning the threshold values themselves, which is the right knob to turn.

Tag your memories at write time. We initially stored observations as plain text and tried to classify them at read time using keyword heuristics. It worked about as well as you’d expect, which is to say it didn’t. Switching to Hindsight’s tag parameter (e.g., tags=["editing-behaviour", "draft-edit", "linkedin"]) meant that recall queries and graph construction could use structured metadata instead of parsing free text. The 30 seconds of effort at write time saved us hours of heuristic maintenance and approximately three existential crises.

Show the user what you learned. The intelligence graph isn’t a gimmick. In our early testing, creators would see observations they disagreed with (“Creator prefers formal tone” when they thought they were casual) and immediately edit a few more drafts to correct the signal. The memory system self-corrected because the creator could see the model’s assumptions and naturally generated counter-evidence. Transparency creates a better feedback loop than any amount of prompt engineering. Your users will train your system for free if you just show them what it thinks.

Fail gracefully at every stage. YouTube throttles downloads. Whisper sometimes hallucinates timestamps (it once confidently transcribed silence as a TED talk). Claude occasionally returns malformed JSON that would make a parser weep. Every stage of our pipeline has a fallback: failed clips become YouTube embeds, missing memories trigger universal heuristics, broken JSON gets regex-parsed as a last resort. The system never shows a blank screen. It always shows something, and that something improves as the infrastructure cooperates. Ship the 80% solution. The remaining 20% will fix itself when you’re not looking.

Building a Self-Hosted AI WhatsApp Agent for Structured Invoice Extraction

As an engineering manager and developer, I constantly look for ways to eliminate repetitive business friction using automation. One of the most common manual bottlenecks is bookkeeping—specifically, reading utility bills or vendor invoices and logging them into financial trackers.

To solve this, I built a production-ready, self-hosted AI Agent using a pure automation stack. It allows users to simply snap a photo of an invoice, send it over WhatsApp, and have the structured data extracted and logged automatically in seconds.

Here is a breakdown of the actual system architecture, the code nodes, and how to handle it using an AI-first approach.

The System Architecture

A robust automation pipeline requires strict separation of concerns. This entire Proof of Concept (POC) runs without an external Node.js server, relying entirely on a self-hosted orchestration engine:

  • Ingress: WhatsApp Business API Cloud Webhooks (configured with a temporary developer test number).
  • Orchestration & Data Flow: Self-hosted n8n environment.
  • Cognitive Layer: Gemini 1.5 Flash (chosen for its native multimodal capabilities, large context window, and fast inference speeds).
  • Data Formatting: Native n8n Code Nodes running isolated JavaScript.

[WhatsApp Client] ──> [Cloud Webhook] ──> [n8n Code Node (JS)] ──> [Gemini 1.5 Flash] ──> [Database/Sheet]

Prerequisites & WhatsApp Developer Setup

To build this yourself, you don’t need a paid enterprise WhatsApp account immediately. You can start building today using Meta’s developer ecosystem:

  1. Create a Developer Account: Head to the Meta for Developers Portal and register.
  2. App Creation: Follow the Meta App Setup Guide to create a new App. Select Other -> Business as your app type.
  3. Add WhatsApp Product: Inside your app dashboard, add the WhatsApp product. Meta will instantly provision a free temporary test number and a test Business Account (WABA) for development.
  4. Configure Webhooks: Point Meta’s webhooks to your n8n production webhook URL to start capturing real-time message payloads.

Step 1: Handling Multimodal Input via Webhooks

When a user uploads an invoice image or PDF via WhatsApp, the webhook doesn’t deliver the file directly; it delivers a media ID.

We use an HTTP Request node to securely request the download URL using your Meta Access Token, pull the binary data, and hold it in the workflow memory to pass directly to our AI node.

Step 2: Designing the System Prompt for Document Extraction

Instead of relying on fragile, traditional OCR software that breaks if a vendor moves a logo, we pass the raw image straight to Gemini 1.5 Flash. The magic lies in instructing the model to act as a strict data parser.

Here is the sample System Prompt structure used in the model configuration:

“You are an expert financial data extraction AI. Your sole task is to analyze the provided invoice or utility bill image and extract data with absolute accuracy.

Follow these strict formatting rules:

  1. Extract the primary Vendor Name.
  2. Extract the Invoice Date and normalize it into standard YYYY-MM-DD format.
  3. Extract the Grand Total Amount as a pure floating-point number.
  4. Extract individual line items into an array containing description, quantity, and total price.

Do not include any conversational text, markdown formatting, or wrappers. Your response must be a single, raw, valid JSON object.”

Step 3: Formatting Data with n8n Code Nodes (JavaScript)

Once Gemini returns the text string, we avoid spinning up an external server or application layer. Instead, we pipe the model’s output directly into an internal n8n Code Node configured to execute JavaScript.

This node isolates the extraction logic, runs a quick safety validation, and formats the properties perfectly for our target database or sheet.

Here is the exact layout of the JavaScript snippet inside the n8n Code Node:

// Loop through incoming items from the Gemini node
for (const item of $input.all()) {
  try {
    // Parse the raw string response from the AI model
    const extractedData = JSON.parse(item.json.output);

    // Format and return the details explicitly for downstream nodes
    item.json.formattedInvoice = {
      vendor: extractedData.vendor_name || 'Unknown Vendor',
      date: extractedData.invoice_date || new Date().toISOString().split('T')[0],
      total: parseFloat(extractedData.grand_total) || 0.0,
      lineItems: extractedData.line_items || []
    };
  } catch (error) {
    // Handle parsing errors safely if the response was malformed
    item.json.formattedInvoice = {
      vendor: 'Parsing Error',
      error: error.message,
      rawOutput: item.json.output
    };
  }
}

return $input.all();

By keeping this execution inside the internal JavaScript node, the data mapping remains incredibly fast, entirely self-contained, and exceptionally easy to debug directly from the execution logs.

Handling Edge Cases in Production

Building a POC is easy; making it production-ready is where the real engineering begins. When deploying this system, you must design around:

  1. Rate Limiting: Managing concurrent incoming webhooks from active users by placing a lightweight Redis queue ahead of the API calls.

  2. Data Security: Ensuring that processed invoice data is deleted from local temporary server storage immediately after database insertion to protect PII.

What’s Next?

This pattern proves that you can build highly sophisticated AI Agents entirely inside a visual workflow manager when paired with robust system prompts and minor inline JavaScript engineering. Next up, I am scaling this to run automated cross-matching against incoming banking transactions.

The full workflow JSON schema will be open-sourced on my GitHub profile soon. Follow along as I map out more production-ready AI engineering architectures.

Three Design Decisions That Shaped the Enterprise RAG Retrieval Pipeline

Enterprise RAG — A practitioner’s build log | Post 3 of 6

A retrieval pipeline has more design surface than it appears. The technology choices — vector search, LLM provider, storage engine — get most of the attention. The structural choices — where filtering happens, how evaluation is wired, what the dashboard connects to — determine whether the system actually works correctly in a production environment.

This post documents three structural decisions I made in Enterprise RAG, the constraint that drove each one, and the cost I accepted.

Decision 1: Lexical retrieval before semantic — sequencing, not a permanent choice

The default retrieval implementation uses token cosine similarity against a local SQLite chunk store (RAG_RETRIEVAL_PROVIDER=local). Not vector embeddings. Not a managed search index. Lexical scoring.

This was a sequencing decision, not a technology preference.

The constraint: Access control validation requires a deterministic retrieval baseline. If retrieval results vary across runs — because embedding models update, because vector indices are rebuilt, because approximate nearest neighbor algorithms introduce non-determinism — the evaluation set becomes unreliable. A restricted_leak_count of zero means nothing if retrieval is non-deterministic and the same query might return different chunks tomorrow.

Lexical retrieval is fully deterministic. Given the same document corpus and the same query, it returns the same ranked chunk list every time. That makes the evaluation set a reliable regression test rather than a probabilistic snapshot.

The accepted cost: Lexical scoring does not capture semantic similarity. A question about “headcount reduction” will not retrieve a chunk that uses the phrase “workforce restructuring” unless there is token overlap. Semantic retrieval closes that gap — at the cost of determinism in the local validation environment.

The Azure AI Search adapter (RAG_RETRIEVAL_PROVIDER=azure_ai_search) is implemented for production use, where semantic and hybrid query modes are available. The retrieval provider is a configuration switch, not a code change. Switching from local to Azure AI Search does not alter the access control layer, the evaluation runner, or the API surface.

Decision 2: API-backed dashboard — not direct database access

The Streamlit dashboard (dashboard/app.py) connects to the FastAPI API layer, not the database directly. Every dashboard operation — querying documents, fetching metrics, running evaluations, reviewing the citation log — goes through an authenticated API call.

This was not a minor implementation choice. It was a deliberate architectural boundary.

The constraint: A dashboard that reads the database directly cannot be deployed in a containerized or cloud environment without granting the dashboard container database credentials. That creates a credential distribution problem: every new environment where the dashboard runs needs database access, which widens the credential surface.

An API-backed dashboard has a single credential requirement: the DASHBOARD_API_URL and optionally DASHBOARD_ADMIN_TOKEN. The dashboard container never holds database credentials. It holds only the API location and the management token. The API enforces authorization. The database credentials stay with the API container.

The accepted cost: Every dashboard operation adds one network hop compared to direct database access. For a local development setup this is negligible. For a cloud-deployed dashboard querying an API on the same virtual network, it is also negligible. The cost is only relevant if the dashboard is running in a significantly different network zone from the API — which would itself be an unusual deployment topology.

The secondary benefit: the API-backed dashboard tests the public API surface on every dashboard interaction. If the dashboard shows correct data, the API is returning correct data. That is a form of continuous integration that direct database access cannot provide.

Decision 3: Evaluation runner as a live API endpoint — not an offline script

The evaluation runner is exposed as POST /eval/run — a standard API endpoint that runs the evaluation set against the live query pipeline and returns metrics directly.

Most RAG evaluation setups I have seen are offline scripts: pull a golden set, run retrieval, compare results, write a report. The script does not call the production API. It calls the retrieval components directly, often with mocked or simplified versions of the access control layer.

The constraint: If the evaluation script bypasses the access control layer, it cannot detect access control failures. A restricted_leak_count computed by calling the retriever directly — without going through the role filter — will always be zero, regardless of whether the filter is actually working in production.

By routing evaluation through POST /eval/run, which calls POST /query internally, the evaluation runner tests the entire pipeline: authentication handling, role filter, retrieval, generation, and citation assembly. Every evaluation case exercises the same code path that a real user request exercises.

The accepted cost: Live evaluation runs against the production database. In a high-traffic environment, running a large evaluation set could add query load. The mitigation is to run evaluations at low-traffic windows or against a staging environment — not to move evaluation back to a disconnected script.

The current evaluation set is small and optimized for repeatable access-control checks. Extending it with larger golden sets, human relevance labels, and answer correctness checks is a documented roadmap item.

One decision I made explicitly not to make yet

Role metadata is currently embedded in document front matter — each markdown document has a allowed_roles field that specifies which roles can retrieve it. This is correct for a local deterministic environment where document metadata is under engineering control.

In production, role context should come from the identity provider — Entra ID claims or OIDC bearer token attributes — not from request body parameters or document-embedded metadata alone. I did not implement full Entra ID role claim integration because it requires a live Azure tenant to validate. The configuration path is documented and the AUTH_PROVIDER=entra setting is implemented. The end-to-end test of role-from-identity-claim requires a real identity provider.

That is a known gap. It is in the production considerations section of docs/security.md, not hidden in implementation comments.

Current limits

  • Lexical retrieval does not capture semantic similarity. Queries with no token overlap with document chunks will not retrieve relevant results even when the content is semantically related.
  • Evaluation set size is calibrated for local access-control validation. Answer quality evaluation — correctness labels, human relevance ratings — is a planned extension.
  • Entra ID role claim integration requires a live Azure tenant for end-to-end validation. The local implementation uses request-body role parameters, which must not be trusted in production without API key authentication.
  • The POST /eval/run endpoint requires the ADMIN_TOKEN when management protection is enabled. Evaluation runs in protected environments require the admin credential.

Next engineering step

Add one document to the corpus with allowed_roles: ["finance"], run POST /eval/run, and verify that the new document appears in the blocked count for non-finance evaluation cases. That single test confirms the role filter is reading document metadata correctly and applying it before scoring.

One question for you

Does your internal RAG evaluation pipeline call the same API endpoints that production queries use, or does it call retrieval components directly? If it bypasses the access control layer, does your restricted_leak_count metric actually measure anything?

Next post: The evaluation metrics that matter for enterprise RAG — and why pass rate alone is not enough to validate a system that handles restricted documents.

How React’s Virtual DOM Works Under the Hood

How React’s Virtual DOM Works Under the Hood

You’ve heard it a hundred times: “React uses a Virtual DOM for performance.”

But what does that actually mean?

In this article, we’ll trace exactly what happens from the moment you call setState() to the moment you see pixels update on screen — no hand-waving, no magic, just the mental model that unlocks everything else in React.

Let’s dig in. 🛠️

The Problem: Why Direct DOM Manipulation Is Slow

Before we understand the solution, we need to understand the problem.

The browser’s DOM (Document Object Model) is a live, tree-shaped object that represents your HTML page. Whenever JavaScript changes a DOM node, the browser has to do expensive work:

  • Style recalculation — which CSS rules now apply?
  • Layout / Reflow — what is every element’s new size and position?
  • Repaint — what pixels need to be redrawn?
Direct DOM Update:
─────────────────
JS writes to DOM
     │
     ▼
Style Recalc → Layout (Reflow) → Paint → Composite
                    ↑
               ALL of this, potentially,
               for a single text change 😬

This is fine for occasional updates. But modern web apps update the UI constantly — typed characters, live data feeds, animations. At high frequency, direct DOM manipulation becomes a bottleneck that shows up as janky, sluggish UIs.

The Solution: Virtual DOM as a Lightweight Proxy

React introduces the Virtual DOM: a plain JavaScript representation of the UI tree that exists purely in memory, completely decoupled from the browser’s rendering engine.

// This is a Virtual DOM node. It's just a JS object.
{
  type: 'button',
  props: {
    className: 'btn-primary',
    onClick: handleClick,
    children: 'Click me'
  }
}

Creating this object costs almost nothing. No browser APIs. No layout. No paint. It’s just memory allocation.

React’s insight: compute changes cheaply in JS-land first, then apply the minimal real-DOM mutations in one go.

Step 1 — Initial Render

When your app first loads:

JSX / Components
      │
      │  (Babel compiles JSX to React.createElement calls)
      ▼
Virtual DOM Tree
(plain JS objects, built entirely in memory)
      │
      │  (React walks the tree and creates real nodes)
      ▼
Real DOM  ←  User sees the UI ✅

React calls your component functions, collects the returned JSX (which becomes React.createElement() calls), and assembles a full Virtual DOM tree. It then makes a single full pass to create and insert real DOM nodes.

This is the only time React builds the entire DOM from scratch.

Step 2 — State or Props Change

Something triggers an update:

const [count, setCount] = useState(0);

// Button clicked:
setCount(count + 1);  // 🔔 React queues a re-render

setCount doesn’t immediately update the DOM. React schedules an update and may batch it with other pending updates for efficiency. Then it moves into the render phase.

Step 3 — New Virtual DOM Tree is Created

React re-runs your component function with the new state. The result is a brand new Virtual DOM tree in memory:

 OLD Virtual DOM          NEW Virtual DOM
 ───────────────          ───────────────
 <div>                    <div>
   <h1>Score</h1>   vs      <h1>Score</h1>
   <span>0</span>           <span>1</span>  ← CHANGED
   <button>+</button>       <button>+</button>
 </div>                   </div>

Both trees exist in memory simultaneously. React needs to figure out what’s different.

Step 4 — Diffing (Reconciliation)

React’s diffing algorithm compares the two trees node-by-node to produce a minimal change set.

Comparing node by node:

  <div>        ✅ Same type → keep, recurse into children
  <h1>Score</h1>  ✅ Same type, same props → NO CHANGE
  <span>       ✅ Same type → check props
    "0" → "1"  ❌ Text content changed → MARK FOR UPDATE
  <button>+</button>  ✅ Same type, same props → NO CHANGE

Change set: [ updateTextContent(spanNode, "1") ]

React uses two key heuristics to keep this efficient:

Different element types → rebuild the subtree

If a <div> becomes a <section> at the same position, React doesn’t try to reuse it. It destroys the old subtree and builds a new one.

Keys → stable identity for list items

// ❌ Bad — React can't tell which item is which after reorder
{items.map(item => <li>{item.name}</li>)}

// ✅ Good — React can match, reuse, and correctly update items
{items.map(item => <li key={item.id}>{item.name}</li>)}

Keys let React correlate old list nodes with new ones. Without them, React might destroy and re-create nodes it could have reused — wasting work and losing local state (like focus or scroll position).

Step 5 — Commit Phase: Minimal Real DOM Updates

Once the diff is done, React enters the commit phase — the only point where it writes to the real DOM:

Change set from diff:
┌──────────────────────────────────────┐
│  • Update text of <span>: 0 → 1     │
│  (1 operation total)                 │
└──────────────────────────────────────┘
         │
         ▼
  Real DOM ← only the <span>'s text node is touched
  Everything else: untouched, no reflow triggered 🎯

Compare this to a naive approach that re-renders the entire list after every keystroke. With React, only the nodes that actually changed are touched.

Why This Improves Performance

Approach What Happens on Update
Direct DOM manipulation Write immediately → browser reflows everything potentially affected
React Virtual DOM Diff in memory → write only changed nodes → minimal browser work

The Virtual DOM isn’t magic. Creating JS objects has a cost too. The real win is batching and minimization: React figures out the cheapest path from the current UI to the desired UI, then executes it in one optimized burst.

The Full Lifecycle — Visual Summary

┌────────────────────────────────────────────────────────┐
│            REACT: RENDER → DIFF → COMMIT               │
│                                                        │
│  setState() / new props                                │
│       │                                                │
│       ▼                                                │
│  ┌─────────────────────────────────────┐               │
│  │         RENDER PHASE                │               │
│  │  Component functions run            │               │
│  │  New Virtual DOM tree built         │               │
│  └──────────────┬──────────────────────┘               │
│                 │                                      │
│                 ▼                                      │
│  ┌─────────────────────────────────────┐               │
│  │      RECONCILIATION (DIFF) PHASE    │               │
│  │                                     │               │
│  │  Old VDOM ◄──── compared ────► New VDOM             │
│  │  Minimal change set computed        │               │
│  └──────────────┬──────────────────────┘               │
│                 │                                      │
│                 ▼                                      │
│  ┌─────────────────────────────────────┐               │
│  │         COMMIT PHASE                │               │
│  │  Only changed nodes written to DOM  │               │
│  │  useLayoutEffect runs               │               │
│  └──────────────┬──────────────────────┘               │
│                 │                                      │
│                 ▼                                      │
│  Browser paints minimal changes ✅                     │
│  useEffect runs                                        │
└────────────────────────────────────────────────────────┘

Quick Reference: Key Concepts

Concept What It Means
Virtual DOM Plain JS object tree mirroring the UI — cheap to create
Re-render React calling your component function again to get new VDOM
Diffing Comparing old VDOM vs new VDOM to find changes
Reconciliation The full process: diff + decide what to update
Commit The moment React writes to the real DOM
Key Stable identity hint for list items during diffing

Things to Know (But Out of Scope Here)

  • React Fiber — React’s internal concurrent scheduler that adds priority lanes and time-slicing on top of this model
  • React 18 Concurrent Mode — useTransition and useDeferredValue let you mark updates as non-urgent
  • React.memo — skips re-render (and therefore skips VDOM creation) when props haven’t changed

Wrapping Up

React’s Virtual DOM is elegant in its simplicity: represent the UI as cheap JS objects, compare states, compute minimal mutations, apply them. The render → diff → commit pipeline is the foundation of React’s performance story — and once you have this mental model, concepts like key, memo, and concurrent features all start making intuitive sense.

If this clicked for you, follow along — next up we’re diving into React Keys deep dive and why index-as-key breaks your app in surprising ways.

Happy coding! ⚛️

Tagged: #react #javascript #webdev #beginners

Build a Dropbox Paper-Style Collaborative Editor with Next.js and Velt💥

Velt landing page

Introduction

Modern apps need built-in collaboration. Users expect to comment on text, see who is online, and receive updates without switching tools. If your editor lacks these features, discussions move to Slack or email. Context gets lost. Engagement drops.

In this guide, we will build a Dropbox Paper-style collaborative editor. You will add inline comments to selected text, show real-time presence, and enable in-app notifications. The stack uses Next.js with the App Router, TipTap for rich text editing, and Velt for collaboration infrastructure.

Building real-time collaboration from scratch is complex and time-consuming. Tools like Velt abstract that complexity so you can focus on product logic instead of infrastructure.

By the end, you will have a fully working collaborative editor running locally.

Tech Stack Overview

This project uses a modern React-based stack built for real-time interaction. Each tool has a clear responsibility and works well in collaborative environments.

  • Next.js (App Router): Provides the application structure and routing layer. The App Router makes it easy to organize layouts, wrap the app with providers, and manage client components cleanly.
  • TipTap Editor: A highly extensible rich text editor built on ProseMirror. It allows precise control over marks and extensions, which makes it ideal for inline commenting and collaborative annotations.
  • Velt SDK: Handles real-time comments, presence, notifications, and collaboration state. It abstracts the backend complexity required for multi-user interaction.
  • Tailwind CSS + shadcn/ui: Enables fast UI development with consistent styling and accessible components.
  • Zustand: Manages lightweight global state, such as switching between predefined users for collaboration simulation.

Project Structure Walkthrough

Before diving into the implementation, it helps to understand how the project is organized. The structure separates layout, collaboration logic, and UI components so each concern stays isolated.

  • app/ — Layout and Velt Initialization

    Contains the root layout and App Router setup. This is where VeltProvider wraps the application and initializes the SDK with your API key and active user.

  • components/ — UI and Core Features

    Holds the main building blocks of the interface, including the editor and the top navigation bar.

  • paper-document.tsx — TipTap + Velt Integration

    Implements the rich text editor and registers the Velt TipTap extension. This file handles rendering comments and adding new ones to selected text.

  • top-bar.tsx — Presence, Notifications, Sidebar Controls

    Displays active users, shows notifications, and provides access to the comments sidebar.

  • helper/userdb.ts — User Switching Logic

    Uses Zustand to manage predefined users and simulate multi-user collaboration.

What is Velt?

Velt is a collaboration SDK that lets you add real-time features like comments, presence, and notifications directly into your product. Instead of building backend infrastructure for multi-user sync, conflict resolution, and event handling, you integrate Velt components and ship collaboration features in days.

Velt landing page

Below are the key features used in this project:

  • Contextual Comments: Add inline comments to specific UI elements or selected text. Comments stay attached to their exact position, enabling precise discussions inside the app.
  • Real-Time Presence: Show who is currently viewing or interacting with the document. Presence indicators update instantly across users.
  • Notifications System: Trigger in-app notifications for mentions, replies, and updates. Keeps users informed without leaving the product.
  • Comments Sidebar: Centralized panel to view, manage, and navigate all comment threads within the document.
  • Multi-User Collaboration Support: Handles user identity, ownership of comments, and live updates so multiple users can interact with the same document seamlessly.

Local Setup (Quick Start)

First, install the Velt client SDK:

npm install @veltdev/client

Velt must be initialized at the root of your application so every component can access the collaboration state. In a Next.js App Router project, this means wrapping your app inside VeltProvider in the root layout.

Create a .env file in the root directory and add your Velt API key:

NEXT_PUBLIC_VELT_ID = your_velt_api_key_here

velt dashboard

Clone the repository and install the dependencies:

git clone https://github.com/Studio1HQ/dropbox-velt.git
cd dropbox-velt
npm install

Start the development server:

npm run dev

Open http://localhost:3000 in your browser.

Dropbox paper UI

If you’re using Velt Agent Skills or MCP-based setup, manual installation steps can be skipped since the AI handles SDK wiring and configuration. This demo uses the standard manual SDK integration.

Wrapping the App with VeltProvider

Before integrating Velt features into the editor, Velt must be available across the application. In this project, the provider is added inside the root layout so every component can access collaboration features.

This setup lives in app/layout.tsx. The VeltProvider wraps the entire app and receives the API key from the environment variable. The ThemeProvider is nested inside it so both theme and collaboration context are globally available.

The provider must sit at the top level because presence, comments, and notifications rely on shared context. If it is mounted deeper in the tree, some components will not receive real-time updates.

"use client";

import { ThemeProvider } from "@/hooks/use-theme";
import { VeltProvider } from "@veltdev/react";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <VeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_ID || ""}>
      <ThemeProvider>{children}</ThemeProvider>
    </VeltProvider>
  );
}

Now that Velt is initialized globally, we can integrate it directly into the TipTap editor.

Adding Inline Comments to TipTap

The core collaboration logic lives inside components/paper-document.tsx. This file initializes the TipTap editor and connects it to Velt using the TipTap comments plugin.

"use client";

import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import {
  TiptapVeltComments,
  renderComments,
  addComment,
} from "@veltdev/tiptap-velt-comments";
import { useCommentAnnotations } from "@veltdev/react";
import { useEffect } from "react";

import { Avatar, AvatarFallback } from "./ui/avatar";
import { Separator } from "./ui/separator";
import { Button } from "./ui/button";
import {
  MessageCircle,
} from "lucide-react";

const EDITOR_ID = "paper-document-editor";

Start by installing @veltdev/tiptap-velt-comments. This package bridges TipTap’s editor state with Velt’s comment system. Once installed, register the TiptapVeltComments extension inside the editor configuration. This enables comment marks on selected text and connects them to Velt’s backend.

Each editor instance must have a unique editor ID. In this project, a fixed document ID is used so comments stay tied to the same document across sessions.

App editor UI

When the editor loads, renderComments syncs and displays existing annotations from Velt. When a user selects text and clicks the comment action in the bubble menu, addComment creates a new contextual thread linked to that selection.

The bubble menu integration ensures users can comment directly on highlighted text. This creates a Google Docs-style inline commenting experience inside your own app.

export function PaperDocument() {
  const editor = useEditor({
    extensions: [
      StarterKit,
      TiptapVeltComments.configure({
        persistVeltMarks: false,
      }),
    ],
    content: {
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            {
              type: "text",
              text:
                "This document contains all the essential information and resources for our current project. Feel free to add comments, suggestions, or ask questions using the tools on the right.",
            },
          ],
        },
        {
          type: "heading",
          attrs: { level: 2 },
          content: [{ type: "text", text: "Overview" }],
        },
        {
          type: "paragraph",
          content: [
            {
              type: "text",
              text:
                "Our team is working on creating a comprehensive solution that addresses the key challenges in collaborative document editing and file sharing. This project aims to deliver a seamless experience for teams of all sizes.",
            },
          ],
        },
        {
          type: "heading",
          attrs: { level: 2 },
          content: [{ type: "text", text: "Key Features" }],
        },
        {
          type: "bulletList",
          content: [
            {
              type: "listItem",
              content: [
                { type: "paragraph", content: [{ type: "text", text: "Real-time collaboration with team members" }] },
              ],
            },
            {
              type: "listItem",
              content: [
                { type: "paragraph", content: [{ type: "text", text: "Inline comments and feedback" }] },
              ],
            },
            {
              type: "listItem",
              content: [
                { type: "paragraph", content: [{ type: "text", text: "Version history and rollback capabilities" }] },
              ],
            },
            {
              type: "listItem",
              content: [
                { type: "paragraph", content: [{ type: "text", text: "Secure sharing with granular permissions" }] },
              ],
            },
          ],
        },
        {
          type: "heading",
          attrs: { level: 2 },
          content: [{ type: "text", text: "Timeline" }],
        },
        {
          type: "paragraph",
          content: [
            {
              type: "text",
              text:
                "The project is divided into multiple phases, each with specific deliverables and milestones. We're currently in Phase 2, focusing on core functionality and user experience improvements."
            },
          ],
        },
        {
          type: "heading",
          attrs: { level: 2 },
          content: [{ type: "text", text: "Resources" }],
        },
        {
          type: "paragraph",
          content: [
            {
              type: "text",
              text:
                "Below you'll find all the files and documents related to this project. Click on any item to preview or download it."
            },
          ],
        },
      ],
    },
    autofocus: false,
    immediatelyRender: false,
  });

Adding Presence & Notifications

Collaboration is not just about comments. Users need awareness and updates in real time. In this project, these features are implemented inside components/top-bar.tsx and connected to the editor view.

"use client";
import {
  useVeltClient,
  VeltCommentsSidebar,
  VeltNotificationsTool,
  VeltPresence,
  VeltSidebarButton,
} from "@veltdev/react";

import { names, userIds, useUserStore } from "@/helper/userdb";
import { Rocket, Share, User } from "lucide-react";
import React, { useEffect, useMemo, useRef } from "react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { FileText, Share2, MoreHorizontal, ChevronDown } from "lucide-react";
import { Button } from "./ui/button";
import useTheme, { ThemeToggleButton } from "@/hooks/use-theme";

VeltPresence displays the users currently viewing the document. It renders active user avatars in the top bar and updates instantly when someone joins or leaves. This gives immediate visibility into who is online and working on the same content.

Velt presence in action

VeltNotificationsTool adds an in-app notification system. It surfaces mentions, replies, and comment activity without requiring page refreshes. Users stay informed while remaining inside the document.

Velt notification in action

VeltSidebarButton toggles the comments panel. When clicked, it opens VeltCommentsSidebar, which lists all comment threads in one place. Users can review discussions, jump to specific annotations, and manage conversations efficiently.

Velt sidebar

export function TopBar() {
  const { theme } = useTheme();
  const { user, setUser } = useUserStore();
  const { client } = useVeltClient();
  const prevUserRef = useRef(user);
  const isInitializingRef = useRef(false); // Prevent overlapping initialization calls

  const predefinedUsers = useMemo(
    () =>
      userIds.map((uid, index) => {
        const avatarUrls = [
          "https://api.dicebear.com/7.x/pixel-art/svg?seed=Nany",
          "https://api.dicebear.com/7.x/pixel-art/svg?seed=Mary",
        ];
        return {
          uid: uid,
          displayName: names[index],
          email: `${names[index].toLowerCase()}@gmail.com`,
          photoUrl: avatarUrls[index],
        };
      }),
    []
  );

  // Initialize user from localStorage if none exists
  useEffect(() => {
    if (typeof window !== "undefined" && !user) {
      const storedUser = localStorage.getItem("user-storage");
      if (!storedUser) {
        setUser(predefinedUsers[0]);
      }
    }
  }, [user, setUser, predefinedUsers]);

  // Handle Velt client initialization, user identification, and document setting
  useEffect(() => {
    if (!client || !user || isInitializingRef.current) {
      console.log("Velt init skipped:", {
        client: !!client,
        user: !!user,
        initializing: isInitializingRef.current,
      });
      return;
    }

    const initializeVelt = async () => {
      isInitializingRef.current = true;
      try {
        // Detect user switch
        const isUserSwitch = prevUserRef.current?.uid !== user.uid;
        prevUserRef.current = user;

        console.log("Starting Velt init for user:", user.uid, { isUserSwitch });

        // Re-identify the user (handles initial and switches)
        const veltUser = {
          userId: user.uid,
          organizationId: "organization_id",
          name: user.displayName,
          email: user.email,
          photoUrl: user.photoUrl,
        };
        await client.identify(veltUser);
        console.log("Velt user identified:", veltUser.userId);
        await client.setDocuments([
          {
            id: "drop-box-velt",
            metadata: { documentName: "drop-box-velt" },
          },
        ]);
        console.log("Velt documents set: drop-box-velt");
      } catch (error) {
        console.error("Error initializing Velt:", error);
      } finally {
        isInitializingRef.current = false;
      }
    };

    initializeVelt();
  }, [client, user]); // Re-run on client or user change

Together, these components create a real-time collaborative environment with visibility, context, and structured discussion.

Multi-User Switching (Simulating Collaboration)

To demonstrate collaboration locally, this project simulates multiple users using a simple state store. The logic lives inside helper/userdb.ts, where predefined users like Nany and Mary are defined with unique IDs and avatars.

The store is built with Zustand. It keeps track of the currently active user and exposes a method to switch between them. When you select a different user from the dropdown in components/top-bar.tsx, the active user state updates instantly.

Because VeltProvider receives the current userId, switching users changes how Velt identifies the session. Presence indicators update automatically, and new comments are attributed to the selected user. Existing comments also display correct ownership based on user identity.

This setup allows you to test real-time presence, comment ownership, and notification behavior on localhost without needing multiple devices or accounts.

import { create } from "zustand";
import { persist } from "zustand/middleware";

export type User = {
  uid: string;
  displayName: string;
  email: string;
  photoUrl?: string;
};

export interface UserStore {
  user: User | null;
  setUser: (user: User) => void;
}

export const userIds = ["user001", "user002"];
export const names = ["Nany", "Mary"];

export const useUserStore = create<UserStore>()(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
    }),
    {
      name: "user-storage",
    }
  )
);

Our final app UI

How Everything Works Together

At a high level, the editor, Velt SDK, and UI components form a connected real-time collaboration loop. Each layer has a clear responsibility, but they operate as a single system.

  • Editor → Velt Plugin → Comments

    The TipTap editor in components/paper-document.tsx registers the Velt comments extension. When a user selects text and adds a comment, the plugin sends the annotation to Velt. Velt syncs it across sessions, and the editor renders it inline.

  • User Change → Presence Update

    The active user is managed in helper/userdb.ts. When switching users from components/top-bar.tsx, the userId passed to VeltProvider changes. Velt immediately updates presence and ensures new comments reflect the correct ownership.

  • Comment → Sidebar → Notification

    When a comment is created, it appears inline and is listed inside VeltCommentsSidebar, and triggers updates in VeltNotificationsTool. All views stay synchronized in real time.

  • Provider → SDK Lifecycle

    VeltProvider in app/layout.tsx manages initialization and global collaboration state. It maintains the real-time connection and ensures all components receive updates consistently.

Demo

Final Result & Takeaways

We built a Dropbox Paper-style collaborative editor with inline comments, real-time presence, and in-app notifications. The entire collaboration layer runs inside the product without relying on external tools. Instead of building real-time infrastructure from scratch, Velt handles synchronization, ownership, and updates. This pattern applies to CMS platforms, dashboards, internal tools, document editors, and any product that needs structured collaboration.

If you are building a collaborative feature, try integrating Velt and ship production-ready comments, presence, and notifications in days instead of months.

Resources

Holy Typos, Batman! How I Built ‘SpellJump’

Hey everyone! 👋

“Kabhi kabhi lagta hai apun hi bhagwan hai,” especially when your code compiles on the very first try. But then, you look at your docstrings, variable names, and READMEs, and realize they are absolutely full of spelling mistakes. Yep, that was me.

I was spending too much time manually hovering and clicking on those annoying red lines. I wanted a tool that didn’t just passively highlight errors, but actively jumped to them like the Batmobile responding to a signal. So, I built SpellJump — an AI-powered typo jumper for VS Code and Cursor.

💡 How I Got the Idea Standard spell checkers feel slow and boring. I wanted a blazing-fast, offline-first tool that let me jump from typo to typo using a simple Ctrl+Shift+J shortcut. And because “Why so serious?”, I decided to bake in some hidden DC Batman Easter Eggs. (Seriously, type “batman” or “gotham” in your editor with the extension on, and see what happens! 🤫)

I built the core extension using TypeScript and the VS Code Extension API. The biggest “Moye Moye” moment during development was handling real-time text document synchronization. You type fast, and the extension needs to parse the text, find typos, and update the Problems panel and wavy underlines instantly—all without lagging the editor. Figuring out the exact text-range mappings while maintaining peak performance was a solid headache.

🧠 Putting my Linux & AI/ML to Work For the core logic, I started with nspell for a fast, offline baseline. But I wanted to push the boundaries with AI. Here is how I utilized my ML and Linux environment for Phase 2:

I fired up my Linux terminal and wrote Python scripts to generate a synthetic dataset of 2,000+ common developer typos.
I trained a baseline model and then brought out the big guns: fine-tuning a DistilBERT model using PyTorch and Hugging Face.
I ran the training workloads in the background using good ol’ nohup

(my train.log file was my best friend for days).
The real trick: You can’t just run heavy PyTorch models inside VS Code without eating up all the RAM. So, I exported the trained model to an ONNX format (spelljump.onnx). Now, it runs locally, completely offline, and lightning-fast!
🚀 Deployment & The 10-Day Milestone When it came to publishing, I chose Open VSX—the open-source alternative to the Microsoft Marketplace that powers editors like Cursor and VSCodium.

Deploying was incredibly smooth. No massive corporate hoops to jump through. All I needed was a GitHub account, the ovsx CLI, and my .vsix package. One command (ovsx publish), and it was live.

And the response? In just 10 days of deployment, SpellJump crossed 185+ downloads! Seeing developers around the world actively use a tool I built from scratch is a feeling better than seeing the Bat-Signal light up the sky.

“It’s not who I am underneath, but what I do that defines me.” And right now, what I do is help you fix your typos faster. 😂

If you use VS Code or Antigravity , give it a spin, try to find the Easter eggs, and let me know your thoughts!

🔗 Check it out on Open VSX: https://open-vsx.org/extension/prakhar-iitj/spelljump#review-details 🔗 Source Code on GitHub: https://github.com/Prakhar54-byte/spelljump

VSCode #Cursor #TypeScript #MachineLearning #OpenSource #OpenVSX #DevOps #BuildInPublic #Batman #DeveloperTools

How to Test Frontend Error States Without Breaking Your Backend

Frontend teams are usually pretty good at testing the happy path. The API returns 200. The response shape is correct. The list has data. The network is fast enough. The user sees the page we designed.

Great.

But products do not live only in the happy path.
They live in all the awkward states around it:

  • the endpoint returns 500
  • the list is empty
  • the request is slow
  • the response is malformed
  • the token expired
  • the user has no permissions
  • staging data changed again
  • the backend endpoint is not ready yet

These states are often where the UI becomes the most important. They are also the states that are hardest to reproduce on demand.

The problem with relying on staging

A lot of teams treat staging as the place where frontend edge cases should be tested. In theory, that makes sense. In practice, staging is rarely in the exact state you need.

  • Maybe you need an empty list, but staging has 400 items.
  • Maybe you need the API to return a 500, but the backend is currently healthy.
  • Maybe you need a slow response to test loading behavior, but the request is too fast.
  • Maybe you need a specific permission error, but nobody wants to mutate test data or add a temporary backend flag just so you can check one UI state.

So the frontend workaround begins.

Common workarounds

There are a few common ways to deal with this.

1. Mock servers
Mock servers are powerful. They are great when you need shared contracts, stable fake environments, or a full backend replacement during development. But they can also be heavy when the task is small.

Sometimes you do not need a full mock environment. You just need this one request to return an empty array for ten minutes while you fix a UI. Setting up a mock server for that can feel like too much ceremony.

2. Local fixtures
Local fixtures are fast and simple.

You create a JSON file, import it somewhere, and render the UI against that data.

This works well for isolated components. But it gets harder when the behavior depends on the actual network flow of the app: auth, routing, loading states, retries, request timing, headers, or runtime conditions.

Fixtures can also drift from real API responses over time.

3. Backend flags or test endpoints
Sometimes the backend team can add a flag, seed special data, or expose a test endpoint. That can be useful, especially in mature teams. But it also creates coordination overhead. The frontend developer has to ask for the state, wait for it, and hope it still exists when QA or design tries to reproduce the same thing later.

4. Browser hacks
We have all done some version of this. Add a temporary throw. Edit code in DevTools. Comment out a fetch call. Hardcode a response. Change a local branch just to force an error state.

It works, but it is fragile. It is also easy to forget, hard to share, and not something you want to rely on for repeatable testing.

A lighter workflow: override the response locally

For many frontend tasks, the ideal workflow is smaller:

  1. Open the app.
  2. See the real request the app is making.
  3. Choose the request you want to control.
  4. Return the response you need.
  5. Reload and test the UI.

No backend change. No proxy setup. No full mock server. No app code changes.
Just one local override for one real browser request.
That is the workflow I have been thinking about while building Ruse.

Example states worth testing

Here are a few states I think every production UI should be easy to test.

Empty list

{
  "items": []
}

Does the UI show a thoughtful empty state? Is the CTA correct? Does the layout collapse awkwardly? Does pagination disappear?

Server error

{
  "error": "Internal server error"
}

Does the page recover? Can the user retry? Is the message useful? Does the app accidentally show a blank screen?

Slow response
A slow API often reveals problems that fast local development hides.

Do loading indicators appear? Do buttons stay disabled? Does the page jump when data arrives? Can the user trigger duplicate actions?

Malformed response

{
  "items": null
}

This is where defensive UI code gets tested.

Does the app fail gracefully, or does one unexpected value take down the entire screen?

Permission error

{
  "error": "You do not have access to this resource"
}

Permission states are product states too. They need design, copy, and behavior.

Why browser-level mocking is useful

Mocking at the browser level is interesting because it starts from what your app is actually doing.

You are not inventing a request from memory. You are not manually recreating the URL, method, and response shape from scratch. You are looking at real traffic and deciding: “for this request, return this instead.”

That makes the loop feel more natural for frontend work.

It is especially useful when you are:

  • building UI before the backend is complete
  • testing error states
  • preparing stable demos
  • reproducing QA bugs
  • checking loading behavior
  • designing empty states
  • working against flaky staging data

Where Ruse fits

I am building Ruse as a Chrome extension for this workflow.

The idea is simple:

  • inspect browser requests
  • create local mock rules
  • return custom responses
  • test the UI state you need
  • keep everything local in the browser
    The first release is intentionally small and local-first. It is not trying to be a full API platform. It is focused on making the first useful mock fast.

Ruse is currently in alpha and going through Chrome Web Store review.

If this workflow sounds useful, I am looking for early testers:

https://ruse.dev/support?topic=early-access

Final thought

Frontend edge cases should not require a backend meeting.

Sometimes you need a full mock server. Sometimes you need contract testing. Sometimes you need proper seeded environments.

But sometimes you just need one request to return one different response so you can finish the UI in front of you.

That small loop is worth making better.

A .NET Dinosaur in Web3. Day 8 — Reading & Writing — WishList Chain

Long time no see.

The dinosaur was a bit busy finishing a module on .NET Windows Forms. To be honest, it’s not my favourite stack — I’m much more into web development — but it’s part of the job.

Now that I’m done with those tasks, I can finally get back to my favourite projects and the whole learn-in-public vibe.

Intro

Day 8 of trying to get into Web3 turned out to be an amazing experience.

It didn’t feel like just another step where you “add a feature and move on.” It felt more like a point where the system actually starts behaving like a system.

From Reading to Writing

Reading from the blockchain feels almost like calling a regular API. You ask for data, you get data, you render it.

Writing introduces a completely different flow.

Now there’s a wallet involved. The user has to confirm the action. The transaction is sent to the network, included in a block, and only then reflected in the UI — and you don’t control that timeline anymore.

At that point it became obvious that building a UI for Web3 is not just about displaying data. It’s about handling uncertainty, delays, and state that lives somewhere outside of your application.

Weird Environment Reality

It seems I might need a more modern laptop — my MacBook almost gave up because of Turbopack. I used it initially, but it was consuming far more resources than expected.

Nothing critical — I switched back to good old webpack.

But if you’re working on an older machine, it’s definitely something to be aware of.

Reality of Building UI

The UI is no longer just reactive — it becomes dependent on external events:

  • the user confirms the transaction (or not)
  • the network processes it
  • the block is mined
  • the state becomes available
    And only then can your UI reflect what actually happened.

This introduces a different kind of thinking. You’re not just updating state — you’re waiting for the system to converge.

Things That Actually Matter

There was a moment where the transaction clearly went through — MetaMask confirmed it — but the UI still showed “No goals yet.”

The contract state had changed, but nothing triggered a refetch on the frontend. From the UI perspective, nothing happened.

And then there was the more serious one.

At some point I realised I was sending transactions to the wrong contract address.

Everything looked correct. MetaMask confirmed the transaction. No errors. No warnings.

And that’s exactly the problem.

In Web3, if you send a transaction to the wrong address — it doesn’t fail in a helpful way. It just… succeeds somewhere else.

There’s no backend validation, no “wrong destination” error. The transaction goes through, and from the system’s perspective — everything is fine.

But your funds are gone.

In my case it was just a test transaction — nothing critical — but it was a very clear reminder: double-check addresses. Every time.

The Moment

And then it all came together.

First goal created. First donation sent. DreamPower increased.

Balance changed. Progress updated. State reflected in the UI.

End-to-end:

MetaMask → smart contract → Sepolia → frontend

At that point it stopped feeling like a collection of separate parts and started feeling like a real system.

What Clicked

The biggest shift for me was understanding that in this setup, the blockchain is not just another service.

It is the backend.

There’s no API layer translating requests. The frontend talks directly to the contract, and that removes an entire layer of abstraction I’m used to in .NET systems.

It also means that things like latency, consistency, and state management behave differently — and you have to design with that in mind.

One More Question

At some point I also started thinking about limits.

How many users can this support? How many goals can a contract realistically store?

Technically — a lot. Practically — every write costs gas, and large reads eventually hit limits. It’s not something that matters right now, but it’s definitely something that will matter later.

Stage: Dinosaur 🦕 — first full interaction. Not just reading. Not just UI. A system that reads, writes, and reacts.

Repo: github.com/alena-dev-soft/wish-list-chain

Follow the journey on Telegram: t.me/dotnetToWeb3