# Build Figma-style multiplayer cursors with WebSockets on Vercel

**Author:** Anshuman Bhardwaj

---

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 [Vercel Function](https://vercel.com/docs/functions) instances. The app pairs a Next.js App Router frontend with a [FastAPI](https://vercel.com/docs/frameworks/backend/fastapi) WebSocket backend, deploys as a single Vercel project using [Services](https://vercel.com/kb/guide/vercel-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, 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.

## Quick start with an AI coding agent

### Vercel plugin

The [Vercel Plugin](https://vercel.com/docs/agent-resources/vercel-plugin) turns your AI coding agent (e.g., OpenAI Codex, Claude Code, or Cursor) into a Vercel expert. It adds skills, slash commands, and current knowledge of the tools this template uses, including Vercel Services and Next.js. The plugin is optional; it isn't required to use the template or to follow this guide.

`npx plugins add vercel/vercel-plugin`

## Prerequisites

Before you begin, make sure you have:

- Node.js 22+ and a package manager (e.g., [pnpm](https://pnpm.io/))
  
- A [Vercel account](https://vercel.com/signup)
  
- Vercel CLI installed (`npm i -g vercel`)
  
- Python 3.14.6 or later
  
- [uv](https://docs.astral.sh/uv/), a Python package and project manager
  

## How it works

The app has two services that deploy as one Vercel 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.
  

## Steps

### 1\. Create the project

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

`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:

`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:

`packages: - frontend`

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

`{ "name": "nextjs-fastapi-real-time-collab-workspace", "version": "0.1.0", "private": true, "packageManager": "pnpm@10.13.1", "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.

### 2\. Configure Vercel Services

Vercel 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`.

`{ "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. `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`. ### 3\. Add the collaboration board The landing page renders the collaboration board and leaves browser-specific behavior to the Client Component. `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. `"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](https://github.com/vercel-labs/nextjs-fastapi-multiplayer-cursors) 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.
  

### 4\. 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.

`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. `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: `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. `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](https://github.com/vercel-labs/nextjs-fastapi-multiplayer-cursors/blob/main/frontend/app/collab-canvas.tsx) 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.
  

### 5\. 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.

``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.

`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. ### 6\. 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. `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. `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. ### 7\. 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. `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. `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](https://github.com/vercel-labs/nextjs-fastapi-multiplayer-cursors/blob/main/frontend/app/collab-canvas.tsx) 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.
  

### 8\. 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.

`@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. `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. ### 9\. 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.    `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.    `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. `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](https://github.com/vercel-labs/nextjs-fastapi-multiplayer-cursors/blob/main/backend/app/api/routes/collaboration.py) 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`.
  

### 10\. 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.

`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. `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. `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 Vercel Function instances. ### 11\. Persist presence with Redis To set up Redis, run the following command: `vercel link vercel integration add upstash` When prompted, select **Upstash for Redis**. Now, pull the development environment variables by running the following command: `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. `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. `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. `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: `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. `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. ### 12\. 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. `PRESENCE_CHANNEL = "collab:presence:events" INSTANCE_ID = uuid.uuid4().hex` Every published event includes the origin instance id: `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. `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: `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](https://github.com/vercel-labs/nextjs-fastapi-multiplayer-cursors/blob/main/backend/app/api/routes/collaboration.py) 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`.
  

### 13\. Run the app locally

Clone the [repository](https://github.com/vercel-labs/nextjs-fastapi-multiplayer-cursors). Then install frontend dependencies:

`pnpm install`

Install backend dependencies from `backend/`:

`cd backend uv sync cd ..`

Run the project Services locally with the Vercel CLI:

`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.
  

### 14\. Deploy and test on Vercel

The `vercel.json` defines the two services Vercel needs:

| Service | Entrypoint            | Route prefix | Framework |
| ------- | --------------------- | ------------ | --------- |
| `web`   | `frontend/`           | `/`          | `nextjs`  |
| `api`   | `backend/app/main.py` | `/server`    | `fastapi` |

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

`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.

## Troubleshooting

### Services 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](https://vercel.com/docs/services).

### FastAPI 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.

### `pnpm 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.

### `pnpm 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.

### A 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 Vercel Services deployments. The browser should use same-origin `/server/ws`.

## Related resources

- [Vercel Services](https://vercel.com/docs/services)
  
- [WebSockets on Vercel Functions](https://vercel.com/docs/functions/websockets)
  
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
  
- [Redis pub/sub](https://redis.io/docs/latest/develop/interact/pubsub/)

---

[View full KB sitemap](/kb/sitemap.md)
