★
Funcionalidades a implementar
Stack Liveblocks para StrategyBoard
Live Cursors
Posición del puntero de cada usuario sincronizada via
useMyPresence con throttle a 50msPresencia
Avatares y nombres en la barra de navegación con
useOthers, actualización instantáneaStorage CRDT
Cards del board como
LiveList<LiveObject>, edición concurrente sin conflictosComentarios
Hilos anclados a cards con
useThreads, menciones y resoluciónAuth 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áticamente1
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_...
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.