Guía Técnica · StrategyBoard SaaS

Colaboración en Tiempo Real
con Liveblocks

Implementación production-ready de cursores en vivo, presencia, almacenamiento CRDT y comentarios para StrategyBoard

Next.js 14 TypeScript Liveblocks @liveblocks/react @liveblocks/node App Router CRDT
strategyboard.app / workspace / cultiva-ia / board / q4-roadmap
AL
MA
SR
JP
4 en línea
Álvaro
María
Sara
Oportunidad
IA en onboarding
Reducir tiempo de activación de 14 a 3 días con guías adaptativas
Álvaro · editando
Objetivo Q4
+40% retención mes 3
Aumentar NRR a 115% mediante expansión de seats en cuentas top
Jorge
Riesgo
Churn segmento SMB
Usuarios con menos de 5 seats tienen LTV insuficiente para el CAC actual
María · comentando
Acción
Rediseño pricing
Nuevo plan Starter 29€/mes con límite de 3 boards y export básico
Sara
Funcionalidades a implementar
Stack Liveblocks para StrategyBoard
🖱️
Live Cursors
Posición del puntero de cada usuario sincronizada via useMyPresence con throttle a 50ms
👥
Presencia
Avatares y nombres en la barra de navegación con useOthers, actualización instantánea
🗂️
Storage CRDT
Cards del board como LiveList<LiveObject>, edición concurrente sin conflictos
💬
Comentarios
Hilos anclados a cards con useThreads, menciones y resolución
🔐
Auth Endpoint
Token JWT por sala con control de acceso por workspace y rol de usuario
📡
Indicador offline
Banner de reconexión con useStatus, cambios encolados automáticamente
1
Arquitectura de la solución
Flujo de datos entre cliente, Liveblocks y tu backend
🌐
Browser A
Usuario Álvaro
Liveblocks
WebSocket / CRDT
🌐
Browser B
Usuario María
🔑
Next.js API
/api/liveblocks-auth
🗄️
Tu DB
workspaces, roles
🏷️
Room
board:{ws}:{id}
2
Configuración del cliente
src/liveblocks.config.ts — tipos TypeScript para StrategyBoard
src/liveblocks.config.ts TypeScript
import { createClient } from "@liveblocks/client";
import { createRoomContext } from "@liveblocks/react";

const client = createClient({
  // Usa endpoint de auth en producción (nunca expongas el secret)
  authEndpoint: "/api/liveblocks-auth",
});

// Presencia: datos efímeros (se pierden al desconectar)
type Presence = {
  cursor: { x: number; y: number } | null;
  selectedCardId: string | null;
  name: string;
  color: string;
};

// Storage: datos persistentes compartidos (CRDT)
type Storage = {
  cards: LiveList<LiveObject<StrategyCard>>;
};

type StrategyCard = {
  id: string;
  label: "Oportunidad" | "Objetivo" | "Riesgo" | "Acción";
  title: string;
  body: string;
  ownerId: string;
  x: number;
  y: number;
};

type UserMeta = {
  id: string;
  info: { name: string; avatar: string; color: string };
};

export const {
  RoomProvider,
  useMyPresence,
  useOthers,
  useSelf,
  useStorage,
  useMutation,
  useStatus,         // Para indicador de conexión
} = createRoomContext<Presence, Storage, UserMeta>(client);
3
Cursores en vivo
src/components/LiveCursors.tsx — posiciones throttleadas a 50ms
src/components/LiveCursors.tsx TSX
import { useCallback, useRef } from "react";
import { useOthers, useMyPresence } from "../liveblocks.config";

export function LiveCursors({ children }: { children: React.ReactNode }) {
  const others = useOthers();
  const [, updateMyPresence] = useMyPresence();
  const lastUpdate = useRef<number>(0);

  const handlePointerMove = useCallback((e: React.PointerEvent) => {
    const now = Date.now();
    if (now - lastUpdate.current < 50) return; // throttle 50ms
    lastUpdate.current = now;
    updateMyPresence({
      cursor: { x: e.clientX, y: e.clientY },
    });
  }, [updateMyPresence]);

  return (
    <div
      style={{ position: "relative", width: "100%", height: "100%" }}
      onPointerMove={handlePointerMove}
      onPointerLeave={() => updateMyPresence({ cursor: null })}
    >
      {children}
      {others.map(({ connectionId, presence, info }) => {
        if (!presence.cursor) return null;
        return <Cursor key={connectionId} {...presence.cursor}
          name={info?.name ?? "Anónimo"} color={info?.color ?? "#6c63ff"} />;
      })}
    </div>
  );
}
4
Almacenamiento CRDT
src/components/StrategyCanvas.tsx — mutaciones atómicas sin conflictos
src/components/StrategyCanvas.tsx TSX
import { useStorage, useMutation } from "../liveblocks.config";
import { LiveList, LiveObject } from "@liveblocks/client";
import { nanoid } from "nanoid";

export function StrategyCanvas() {
  // useStorage suscribe este componente a cambios en cards
  const cards = useStorage((root) => root.cards);

  // useMutation: función que modifica storage de forma atómica
  const addCard = useMutation(({ storage }, data: Omit<StrategyCard, "id">) => {
    const cards = storage.get("cards");
    cards.push(new LiveObject({ ...data, id: nanoid() }));
  }, []);

  const updateCard = useMutation(({ storage }, id: string, patch: Partial<StrategyCard>) => {
    const card = storage.get("cards").find((c) => c.get("id") === id);
    if (card) {
      // Solo sincroniza los campos modificados → eficiente en banda
      Object.entries(patch).forEach(([k, v]) => card.set(k as any, v));
    }
  }, []);

  return (
    <div className="canvas-grid">
      {cards?.map((card) => (
        <CardComponent key={card.id} card={card}
          onUpdate={(patch) => updateCard(card.id, patch)} />
      ))}
    </div>
  );
}
5
Endpoint de autenticación
app/api/liveblocks-auth/route.ts — control por workspace y rol
app/api/liveblocks-auth/route.ts TypeScript · Next.js
import { Liveblocks } from "@liveblocks/node";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { checkBoardAccess } from "@/lib/permissions";

const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST(req: NextRequest) {
  const session = await getServerSession();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { room } = await req.json();
  // Extrae workspaceId del patrón board:{workspaceId}:{boardId}
  const [, workspaceId] = room.split(":");

  const hasAccess = await checkBoardAccess(session.user.id, workspaceId);
  if (!hasAccess) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const liveblocksSession = liveblocks.prepareSession(session.user.id, {
    userInfo: {
      name: session.user.name!,
      avatar: session.user.image ?? "",
      color: deterministicColor(session.user.id), // estable por userId
    },
  });

  liveblocksSession.allow(room, liveblocksSession.FULL_ACCESS);

  const { status, body } = await liveblocksSession.authorize();
  return new NextResponse(body, { status });
}
6
Instalación
Paquetes npm necesarios para StrategyBoard
Core (obligatorio)
npm install @liveblocks/client @liveblocks/react
Backend auth + UI
npm install @liveblocks/node @liveblocks/react-ui
Edición de texto rica (Yjs)
npm install @liveblocks/yjs yjs
Variables de entorno (.env.local)
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_...
LIVEBLOCKS_SECRET_KEY=sk_...
Mejores prácticas
Para StrategyBoard en producción
1
Auth en producción — La public key solo para dev. El endpoint /api/liveblocks-auth valida sesión y permisos antes de emitir token.
2
Throttle cursors a 50ms — useRef para comparar timestamps; el canal de presencia es el más ruidoso.
3
Mutaciones granulares — card.set("title", v) no card.update({...card, title: v}). Solo viaja el campo cambiado.
4
Naming de sala consistente — Patrón board:{workspaceId}:{boardId} facilita parsear permisos en el auth endpoint.
5
Indicador de conexión — useStatus() devuelve "connected" | "reconnecting" | "disconnected". Muestra banner en canvas cuando no es "connected".
6
Prueba con múltiples pestañas — Abre 2-3 ventanas del mismo board durante desarrollo para verificar sincronización en tiempo real.