Skip to content
Docs

Build Figma-style multiplayer cursors with WebSockets on Edgenivo

Learn how to build Figma-style multiplayer cursors with Next.js and FastAPI, kept consistent across multiple Edgenivo Function instances using WebSockets and Redis.

Anshuman BhardwajContent Engineer

Build a collaborative canvas where every visitor can see who else is present and watch remote cursors move in real time, even when the backend spans multiple Edgenivo Function instances. The app pairs a Next.js App Router frontend with a FastAPI WebSocket backend, deploys as a single Edgenivo project using Services, and uses Redis to share state across instances.

In this guide, you'll start with a Next.js and FastAPI project, add a browser collaboration board, connect it to a WebSocket endpoint, and then make the real-time layer production-ready with Redis-backed snapshots and pub/sub.

Deploy the template now, or read on for a deeper look at how it all works.

Figma-style multiplayer cursors with WebSockets

A canvas app with real-time multiplayer cursors showing all active users.

Deploy Template

Copy link to headingQuick start with an AI coding agent

Agent Prompt

I want to build a real-time multiplayer cursor app using the Next.js + FastAPI WebSockets real-time cursors template. Read the setup instructions at https://agent-resources.dev/websockets-realtime-cursors-template.md and follow them. They cover deploying the template with Redis, running it locally with Edgenivo Services, building on Next.js App Router and FastAPI WebSockets, and understanding how real-time cursor presence works overall.

This guide covers the core implementation. The AI assistant prompt above covers all the details your coding agent needs, and you can see the full implementation in the full repository.

Copy link to headingEdgenivo plugin

The Edgenivo Plugin turns your AI coding agent (e.g., OpenAI Codex, Claude Code, or Cursor) into an Edgenivo expert. It adds skills, slash commands, and current knowledge of the tools this template uses, including Edgenivo Services and Next.js. The plugin is optional; it isn't required to use the template or to follow this guide.

Terminal
npx plugins add vercel/vercel-plugin

Copy link to headingPrerequisites

Before you begin, make sure you have:

  • Node.js 22+ and a package manager (e.g., pnpm)
  • A Edgenivo account
  • Edgenivo CLI installed (npm i -g vercel)
  • Python 3.14.6 or later
  • uv, a Python package and project manager

Copy link to headingHow it works

The app has two services that deploy as one Edgenivo project:

  • The Next.js service renders the collaboration board at /.
  • The FastAPI service accepts WebSocket connections at /server/ws.
  • The browser creates a per-tab participant, connects to the socket, and sends cursor coordinates as the pointer moves.
  • FastAPI stores the latest participant state and broadcasts presence, cursor, and leave messages.
  • Redis keeps snapshots consistent across instances and carries pub/sub events between FastAPI processes.

Copy link to headingSteps

Copy link to heading1. Create the project

Start from an empty workspace. Create the project folder first, then scaffold the two services inside it:

Terminal
mkdir nextjs-fastapi-real-time-collab
cd nextjs-fastapi-real-time-collab
pnpm create next-app@latest frontend --yes
vc init fastapi backend

The Next.js app owns the browser UI. The FastAPI app owns the WebSocket server.

Add the cursor interpolation dependency to the frontend package:

Terminal
pnpm --dir frontend add perfect-cursors

Create a pnpm workspace file at the repository root so root commands can install and run the frontend package cleanly:

pnpm-workspace.yaml
packages:
- frontend

Keep the root package.json as a workspace command wrapper, not as the Next.js app itself:

package.json
{
"name": "nextjs-fastapi-real-time-collab-workspace",
"version": "0.1.0",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"build": "pnpm --dir frontend build",
"lint": "pnpm --dir frontend lint"
}
}

At this point, the repository has the same shape the rest of the guide assumes: frontend/ for Next.js, backend/ for FastAPI, and the root for workspace and deployment configuration.

Copy link to heading2. Configure Edgenivo Services

Edgenivo Services lets the Next.js app and the FastAPI backend deploy together while keeping each service in its natural framework. The frontend owns /; the backend is mounted under /server.

vercel.json
{
"services": {
"web": {
"root": "frontend/",
"framework": "nextjs"
},
"api": {
"root": "backend/",
"entrypoint": "app.main:app",
"framework": "fastapi"
}
},
"rewrites": [
{ "source": "/server/(.*)", "destination": { "service": "api" } },
{ "source": "/(.*)", "destination": { "service": "web" } }
]
}

The FastAPI app declares its WebSocket route as /ws, and the top-level /server/(.*) rewrite sends browser traffic to the api service. Keeping both services on one origin avoids CORS setup and lets the browser use a relative WebSocket base URL in production.

The public rewrite sends requests to the service with the /server prefix still present, so the FastAPI app strips that prefix before route matching. That lets the backend remain natural when run by itself while still serving /server/ws and /server/api/... through Services.

backend/app/main.py
class ServicePrefixMiddleware:
def __init__(self, app, prefix: str) -> None:
self.app = app
self.prefix = prefix
self.prefix_bytes = prefix.encode()
async def __call__(self, scope, receive, send):
if scope["type"] in {"http", "websocket"}:
path = scope.get("path", "")
if path == self.prefix or path.startswith(f"{self.prefix}/"):
scope = {
**scope,
"path": path[len(self.prefix) :] or "/",
"root_path": f"{scope.get('root_path', '')}{self.prefix}",
}
raw_path = scope.get("raw_path")
if isinstance(raw_path, bytes) and raw_path.startswith(self.prefix_bytes):
scope["raw_path"] = raw_path[len(self.prefix_bytes) :] or b"/"
await self.app(scope, receive, send)
app.add_middleware(ServicePrefixMiddleware, prefix="/server")
app.include_router(api_router, prefix=settings.API_V1_STR)
app.include_router(collaboration_router)

The backend's own routes stay short: the versioned API router serves /api/v1, and the collaboration router serves /ws. Through Services, those become /server/api/v1 and /server/ws.

Copy link to heading3. Add the collaboration board

The landing page renders the collaboration board and leaves browser-specific behavior to the Client Component.

frontend/app/page.tsx
import { CollabCanvas } from "./collab-canvas";
export default function Home() {
return <CollabCanvas />;
}

CollabCanvas is marked with "use client" because it uses state, effects, WebSocket, sessionStorage, ResizeObserver, and pointer events.

frontend/app/collab-canvas.tsx
"use client";
import { PerfectCursor } from "perfect-cursors";
import {
type PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";

The root layout handles metadata and fonts with next/font, so the realtime component can stay focused on socket state and rendering.

For brevity, this guide does not inline every part of the App Router shell. The full repo also includes:

  • app/layout.tsx: Geist Sans and Geist Mono setup, the root <html> element, and the shared body classes.
  • app/globals.css: Tailwind CSS import, font variables, and global body colors.
  • app/page.tsx: the single public route that renders the board.

Copy link to heading4. Create a per-tab visitor identity

Each browser tab gets a visitor id, name, and cursor color. The app stores that identity in sessionStorage, which means a reload keeps the same cursor while a second tab behaves like a separate collaborator.

frontend/app/collab-canvas.tsx
const colors = [
"#1e9df5",
"#7b61ff",
"#22c079",
"#fb6e6e",
"#f5a623",
"#ff4fa3",
"#14b8c4",
"#5566ff",
];
const names = ["Mira", "Kai", "Rin", "Avery", "Noor", "Sol", "Ira", "Jules"];
const sessionUserKey = "collab-session-user";

The first render uses a placeholder user. A browser-only effect then creates or restores the real session user.

frontend/app/collab-canvas.tsx
useEffect(() => {
const timer = window.setTimeout(() => {
setSelf(createLocalUser());
setIsReady(true);
}, 0);
return () => window.clearTimeout(timer);
}, []);

createLocalUser restores a saved tab identity when it can, otherwise it creates a fresh one:

frontend/app/collab-canvas.tsx
function createLocalUser(): PresenceUser {
const savedUser = sessionStorage.getItem(sessionUserKey);
if (savedUser) {
try {
const parsedUser = JSON.parse(savedUser) as Partial<PresenceUser>;
if (parsedUser.id && parsedUser.name && parsedUser.color) {
return {
id: parsedUser.id,
name: parsedUser.name,
color: parsedUser.color,
x: 0.5,
y: 0.5,
updatedAt: Date.now() / 1000,
};
}
} catch {
sessionStorage.removeItem(sessionUserKey);
}
}
const randomIndex = Math.floor(Math.random() * names.length);
return {
id: crypto.randomUUID(),
name: names[randomIndex],
color: colors[randomIndex % colors.length],
x: 0.5,
y: 0.5,
updatedAt: Date.now() / 1000,
};
}

After the visitor is ready, the app writes the stable identity back to sessionStorage whenever the name or color changes.

frontend/app/collab-canvas.tsx
useEffect(() => {
if (!isReady) {
return;
}
sessionStorage.setItem(
sessionUserKey,
JSON.stringify({ id: self.id, name: self.name, color: self.color }),
);
}, [isReady, self.id, self.name, self.color]);

For brevity, this guide does not inline every identity helper. The complete file also includes:

  • defaultUser, which gives React a stable placeholder before sessionStorage is available.
  • updateName, which trims names to 28 characters before updating local state.
  • updateColor, which changes the local cursor color and triggers the socket effect with the latest profile.
  • parseSocketMessage, which ignores invalid client-side JSON frames.

Copy link to heading5. Connect the browser to WebSockets

The browser connects to /server/ws by default. When the frontend and backend run as separate local servers, NEXT_PUBLIC_WS_URL can point directly at FastAPI.

frontend/app/collab-canvas.tsx
function createWebSocketUrl(user: Pick<PresenceUser, "id" | "name" | "color">) {
const baseUrl = process.env.NEXT_PUBLIC_WS_URL ?? "/server";
const url = new URL(
`${baseUrl.replace(/\/$/, "")}/ws`,
window.location.origin,
);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("id", user.id);
url.searchParams.set("name", user.name);
url.searchParams.set("color", user.color);
return url.toString();
}

The connection effect opens the socket after the visitor identity is ready. On open, it sends a hello message with the current name and color. On close, it reconnects with a capped backoff.

frontend/app/collab-canvas.tsx
useEffect(() => {
if (!isReady) {
return;
}
let cancelled = false;
const connect = () => {
const ws = new WebSocket(
createWebSocketUrl({ id: selfId, name: selfName, color: selfColor }),
);
socketRef.current = ws;
setConnectionState("connecting");
ws.addEventListener("open", () => {
reconnectDelayRef.current = 800;
setConnectionState("open");
ws.send(
JSON.stringify({ type: "hello", name: selfName, color: selfColor }),
);
});
ws.addEventListener("close", () => {
setConnectionState("closed");
if (cancelled) {
return;
}
reconnectTimerRef.current = setTimeout(
connect,
reconnectDelayRef.current,
);
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 1.6,
8000,
);
});
};
connect();
}, [isReady, selfId, selfName, selfColor]);

The full effect also parses incoming socket messages, reduces them into the user map, clears reconnect timers on unmount, clears pending cursor timers, and closes the socket when the component leaves the page.

Copy link to heading6. Send cursor movement events

The board sends normalized cursor coordinates between 0 and 1. That makes cursor positions independent of each visitor's screen size.

frontend/app/collab-canvas.tsx
const sendCursor = (event: PointerEvent<HTMLDivElement>) => {
const board = boardRef.current;
const socket = socketRef.current;
if (!board || !socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const rect = board.getBoundingClientRect();
const x = clamp((event.clientX - rect.left) / rect.width);
const y = clamp((event.clientY - rect.top) / rect.height);
scheduleCursorSend(x, y);
};

Cursor messages are throttled to one send every 80ms. The client still updates its own state before sending so the local cursor feels immediate.

frontend/app/collab-canvas.tsx
const cursorSendIntervalMs = 80;
const flushCursor = () => {
cursorSendTimerRef.current = null;
const pendingCursor = pendingCursorRef.current;
const socket = socketRef.current;
if (!pendingCursor || !socket || socket.readyState !== WebSocket.OPEN) {
return;
}
pendingCursorRef.current = null;
lastSentAtRef.current = Date.now();
const nextSelf = {
...selfRef.current,
...pendingCursor,
updatedAt: Date.now() / 1000,
};
setSelf(nextSelf);
setUsers((currentUsers) => ({ ...currentUsers, [nextSelf.id]: nextSelf }));
socket.send(
JSON.stringify({
type: "cursor",
x: pendingCursor.x,
y: pendingCursor.y,
}),
);
};

scheduleCursorSend keeps the most recent pointer position in a ref and flushes it when the throttle window allows another send.

Copy link to heading7. Create smooth remote cursor motion

The server sends cursor snapshots as discrete points. The client uses perfect-cursors to animate each remote cursor between those points.

frontend/app/collab-canvas.tsx
function RemoteCursor({
boardSize,
user,
}: {
boardSize: BoardSize;
user: PresenceUser;
}) {
const cursorRef = useRef<HTMLDivElement | null>(null);
const perfectCursorRef = useRef<PerfectCursor | null>(null);
const { height, width } = boardSize;
const { x, y } = user;
const [initialPoint] = useState(() => [x * width, y * height]);
const moveCursor = useCallback((point: number[]) => {
const cursor = cursorRef.current;
if (!cursor) {
return;
}
cursor.style.transform = getCursorTransform(point);
}, []);
useEffect(() => {
const perfectCursor = new PerfectCursor(moveCursor);
perfectCursorRef.current = perfectCursor;
perfectCursor.addPoint(initialPoint);
return () => {
perfectCursor.dispose();
perfectCursorRef.current = null;
};
}, [initialPoint, moveCursor]);
useEffect(() => {
perfectCursorRef.current?.addPoint([x * width, y * height]);
}, [height, width, x, y]);
}

The sidebar is just a view of the current user map. The current visitor is shown first, and remote visitors are sorted by name.

frontend/app/collab-canvas.tsx
const otherUsers = useMemo(
() =>
Object.values(users)
.filter((user) => user.id !== selfId)
.sort((a, b) => a.name.localeCompare(b.name)),
[selfId, users],
);

For brevity, this guide does not include every part of the board UI inline. The full file also includes:

  • The board header, status badge, name input, and color swatches.
  • The grid canvas and static prototype cards that make cursor movement easy to see.
  • PresenceRow, which renders each visitor in the sidebar.
  • ResizeObserver state so remote cursor coordinates scale with the actual board size.
  • statusLabel and statusClassName, which turn socket state into compact UI.

Copy link to heading8. Accept WebSocket connections in FastAPI

The FastAPI route accepts the socket, creates a participant from the query parameters, stores it, sends an initial snapshot, and announces the join.

backend/app/api/routes/collaboration.py
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
presence_store = await get_store()
await presence_store.start_listener(handle_remote_event)
participant = Participant(
id=clean_text(websocket.query_params.get("id"), fallback=f"user-{time.time_ns()}"),
name=clean_text(websocket.query_params.get("name"), fallback="Visitor"),
color=clean_color(websocket.query_params.get("color"), fallback="#2563eb"),
x=0.5,
y=0.5,
updated_at=time.time(),
)
async with clients_lock:
clients[participant.id] = websocket
await presence_store.upsert(participant)

The initial snapshot gives the new client a complete roster. Then presence tells everyone else that this participant joined.

backend/app/api/routes/collaboration.py
await websocket.send_json(
{
"type": "snapshot",
"selfId": participant.id,
"users": await presence_store.snapshot(),
"stateStore": presence_store.label,
}
)
await announce({"type": "presence", "user": serialize_user(participant)}, skip_id=participant.id)

Full snapshots make reconnects simple. A reconnecting client does not need to replay missed events; it can replace local state from the latest snapshot.

Copy link to heading9. Handle presence and cursor messages

The browser sends two message types:

  • hello updates the participant's name and color.
  • cursor updates the participant's normalized pointer position.
backend/app/api/routes/collaboration.py
while True:
message = parse_message(await websocket.receive_text())
if not message:
continue
if message.get("type") == "hello":
participant.name = clean_text(message.get("name"), fallback=participant.name)
participant.color = clean_color(message.get("color"), fallback=participant.color)
participant.updated_at = time.time()
await presence_store.upsert(participant)
await announce({"type": "presence", "user": serialize_user(participant)})
if message.get("type") == "cursor":
participant.x = clamp_float(message.get("x"), fallback=participant.x)
participant.y = clamp_float(message.get("y"), fallback=participant.y)
participant.updated_at = time.time()
await announce_realtime(
presence_store,
{"type": "cursor", "user": serialize_user(participant)},
skip_id=participant.id,
)
await schedule_cursor_snapshot(presence_store, participant)

The client receives four message types:

  • snapshot replaces the local user map.
  • presence adds or updates a participant.
  • cursor updates a participant's coordinates.
  • leave removes a participant.
frontend/app/collab-canvas.tsx
function reducePresence(
currentUsers: Record<string, PresenceUser>,
message: SocketMessage,
selfId: string,
) {
if (message.type === "snapshot") {
return Object.fromEntries(message.users.map((user) => [user.id, user]));
}
if (message.type === "presence" || message.type === "cursor") {
return { ...currentUsers, [message.user.id]: message.user };
}
if (message.type === "leave" && message.id !== selfId) {
const nextUsers = { ...currentUsers };
delete nextUsers[message.id];
return nextUsers;
}
return currentUsers;
}

On disconnect, the backend removes the socket, cancels any delayed cursor persistence task, deletes the participant from the store, and broadcasts a leave event.

backend/app/api/routes/collaboration.py
finally:
async with clients_lock:
clients.pop(participant.id, None)
await cancel_cursor_snapshot(participant.id)
await presence_store.remove(participant.id)
await announce({"type": "leave", "id": participant.id})

These snippets focus on the core collaboration messages. The full files also include:

  • parse_message, which accepts Redis byte payloads and WebSocket text frames.
  • clean_text, clean_color, and clamp_float, which keep untrusted socket input bounded.
  • serialize_user, which converts Python updated_at into the frontend's updatedAt.

Copy link to heading10. Add an in-memory presence store

The memory store speeds up local development because it does not require Redis. It is sufficient for one FastAPI process to handle all connected sockets.

backend/app/api/routes/collaboration.py
class InMemoryPresenceStore:
label = "memory"
def __init__(self) -> None:
self.participants: dict[str, Participant] = {}
self.lock = asyncio.Lock()
async def snapshot(self) -> list[dict[str, Any]]:
async with self.lock:
self.prune_stale()
return [serialize_user(user) for user in self.participants.values()]
async def upsert(self, participant: Participant) -> None:
async with self.lock:
self.participants[participant.id] = participant
async def remove(self, participant_id: str) -> None:
async with self.lock:
self.participants.pop(participant_id, None)

The memory store uses the same interface as Redis, so the WebSocket route does not need to know which persistence backend is active.

backend/app/api/routes/collaboration.py
class PresenceStore(Protocol):
label: str
async def snapshot(self) -> list[dict[str, Any]]:
...
async def upsert(self, participant: Participant) -> None:
...
async def remove(self, participant_id: str) -> None:
...
async def publish(self, event: dict[str, Any]) -> None:
...
async def start_listener(self, on_event: Any) -> None:
...

The store factory chooses Redis when REDIS_URL exists and falls back to memory otherwise.

backend/app/api/routes/collaboration.py
async def get_store() -> PresenceStore:
global store
async with store_lock:
if store is not None:
return store
redis_url = os.environ.get("REDIS_URL")
if redis_url:
redis_store = RedisPresenceStore(redis_url)
await redis_store.connect()
store = redis_store
else:
store = InMemoryPresenceStore()
logger.info("Collaboration persistence backend: %s", store.label)
return store

Redis is optional locally. Without REDIS_URL, FastAPI uses the in-memory store. That is enough for one local backend process, but in production it does not share state across many Edgenivo Function instances.

Copy link to heading11. Persist presence with Redis

To set up Redis, run the following command:

Terminal
vercel link
vercel integration add upstash

When prompted, select Upstash for Redis.

Now, pull the development environment variables by running the following command:

Terminal
vercel env pull

This command creates a .env.local file with the REDIS_URL environment variable.

Redis stores the latest participant payloads in a hash and their last activity timestamps in a sorted set.

backend/app/api/routes/collaboration.py
PRESENCE_HASH_KEY = "collab:presence:users"
PRESENCE_ACTIVITY_KEY = "collab:presence:activity"
STALE_AFTER_SECONDS = 120

Writing a participant updates both structures and adds expirations so old demo state cannot live forever.

backend/app/api/routes/collaboration.py
async def upsert(self, participant: Participant) -> None:
payload = json.dumps(serialize_user(participant))
pipe = self.redis.pipeline()
pipe.hset(PRESENCE_HASH_KEY, participant.id, payload)
pipe.zadd(PRESENCE_ACTIVITY_KEY, {participant.id: participant.updated_at})
pipe.expire(PRESENCE_HASH_KEY, STALE_AFTER_SECONDS * 4)
pipe.expire(PRESENCE_ACTIVITY_KEY, STALE_AFTER_SECONDS * 4)
await pipe.execute()

Reading a snapshot first removes stale ids, then returns the current roster.

backend/app/api/routes/collaboration.py
async def snapshot(self) -> list[dict[str, Any]]:
await self.prune_stale()
return [json.loads(user) for user in await self.redis.hvals(PRESENCE_HASH_KEY)]

Clean leaves remove the user from both Redis structures:

backend/app/api/routes/collaboration.py
async def remove(self, participant_id: str) -> None:
pipe = self.redis.pipeline()
pipe.hdel(PRESENCE_HASH_KEY, participant_id)
pipe.zrem(PRESENCE_ACTIVITY_KEY, participant_id)
await pipe.execute()

Cursor movement is high-volume, so the backend publishes cursor events immediately but writes cursor snapshots to Redis on a small delay.

backend/app/api/routes/collaboration.py
async def schedule_cursor_snapshot(presence_store: PresenceStore, participant: Participant) -> None:
if presence_store.label == "memory":
return
async with cursor_snapshot_lock:
task = cursor_snapshot_tasks.get(participant.id)
if task and not task.done():
return
cursor_snapshot_tasks[participant.id] = schedule_background(
persist_cursor_snapshot(presence_store, participant),
"persist throttled cursor snapshot",
)

This gives reconnecting clients a recent cursor position without turning every pointer frame into a Redis write.

Copy link to heading12. Fan out events with Redis PubSub

Redis storage gives every instance the same snapshot, but it does not push live messages to sockets held by another instance. Pub/sub handles that fanout.

backend/app/api/routes/collaboration.py
PRESENCE_CHANNEL = "collab:presence:events"
INSTANCE_ID = uuid.uuid4().hex

Every published event includes the origin instance id:

backend/app/api/routes/collaboration.py
async def publish(self, event: dict[str, Any]) -> None:
await self.redis.publish(PRESENCE_CHANNEL, json.dumps({"origin": INSTANCE_ID, "event": event}))

Each Redis-backed instance subscribes to the shared channel and ignores events it published itself.

backend/app/api/routes/collaboration.py
async for message in pubsub.listen():
payload = parse_message(message.get("data"))
if not payload or payload.get("origin") == INSTANCE_ID:
continue
event = payload.get("event")
if isinstance(event, dict):
await on_event(event)

The broadcast path is the same whether an event came from the local socket or a remote Redis message:

backend/app/api/routes/collaboration.py
async def announce(payload: dict[str, Any], skip_id: str | None = None) -> None:
await broadcast(payload, skip_id=skip_id)
await (await get_store()).publish(payload)
async def handle_remote_event(payload: dict[str, Any]) -> None:
await broadcast(payload)

The sequence is:

  1. A user moves their cursor.
  2. The FastAPI instance sends the cursor event to its own connected clients.
  3. The instance publishes the cursor event to Redis.
  4. Other FastAPI instances receive the pub/sub event.
  5. Each instance broadcasts the event to its own connected clients.

This guide highlights the core pieces for the Redis integration. The full file also includes:

  • TLS CA configuration for rediss:// Redis URLs through certifi.
  • A listener lock so only one pub/sub task starts per process.
  • A reconnect loop for Redis pub/sub failures.
  • schedule_background, which logs async task failures instead of dropping them silently.
  • Stale-client cleanup when a WebSocket send fails during broadcast.

Copy link to heading13. Run the app locally

Clone the repository. Then install frontend dependencies:

Terminal
pnpm install

Install backend dependencies from backend/:

Terminal
cd backend
uv sync
cd ..

Run the project Services locally with the Edgenivo CLI:

Terminal
vercel dev -L

Open http://localhost:3000 in two browser tabs. Move your cursor across the board.

You should see:

  • Each tab appears in the visitor list.
  • Remote cursors move smoothly across the board.
  • Changing a name or color updates the other tab.
  • Closing a tab removes that participant.
Figma-style multiplayer cursor with WebSockets

Copy link to heading14. Deploy and test on Edgenivo

The vercel.json defines the two services Edgenivo needs:

ServiceEntrypointRoute prefixFramework
webfrontend//nextjs
apibackend/app/main.py/serverfastapi

Add REDIS_URL in Edgenivo Environment Variables for shared presence across instances. Deploy the project:

Terminal
vercel --prod

Open the production URL on two devices. The browser should connect over wss:// to the same origin, and both devices should share one presence roster.

Copy link to headingTroubleshooting

Copy link to headingServices configuration is invalid

Cause: vercel.json has an unsupported service property, often from older Services examples.

Fix: Keep only supported service keys like root, framework, entrypoint, bindings, functions, and routing keys. For more information, see Services docs.

Copy link to headingFastAPI works directly but fails through /server

Cause: The public rewrite sends the /server prefix into the api service.

Fix: Confirm ServicePrefixMiddleware is registered in backend/app/main.py and strips /server for both HTTP and WebSocket scopes.

Copy link to headingpnpm dev fails or starts the wrong app

Cause: The Next.js app lives in frontend/; the repo root is only a workspace wrapper.

Fix: Use vercel dev -L for Services, or pnpm --dir frontend dev for split frontend-only development.

Copy link to headingpnpm install prompts or hangs

Cause: pnpm is trying to confirm module cleanup or is using stale root package state.

Fix: Run from the repository root after the frontend/ move, keep .npmrc with confirm-modules-purge=false, and confirm pnpm-lock.yaml has a frontend importer.

Copy link to headingA deployed app connects to the wrong URL

Cause: NEXT_PUBLIC_WS_URL is set in a combined Services deployment.

Fix: Remove NEXT_PUBLIC_WS_URL for normal Edgenivo Services deployments. The browser should use same-origin /server/ws.

Copy link to headingFAQ

Why use normalized cursor coordinates?

Normalized coordinates let every client render the cursor relative to its own board size. A cursor at { "x": 0.5, "y": 0.5 } appears in the center no matter how large the viewer's screen is.

Why put the user id in session storage?

sessionStorage is scoped to a browser tab and survives reloads. That makes local testing natural: two tabs look like two collaborators, while a reload keeps the same cursor identity.

Do I need Redis locally?

No. The in-memory store is enough for one local FastAPI process. Redis is needed when presence must be shared across multiple FastAPI instances, devices, or deployed environments.

Why publish cursor events before writing every cursor to Redis?

Cursor events need low latency, but cursor snapshots only need to be recent enough for reconnects. Pub/sub carries the live event immediately, while the throttled Redis write keeps the shared snapshot fresh without writing every pointer frame.

Can this support rooms or documents?

Yes. Add a room or document ID to the WebSocket URL, include it in Redis keys and pub/sub channels, and only broadcast events to clients connected to the same room. Check out the Notion-style real-time presence guide for room-based implementation.

More Edgenivo Services guides