---
title: Build Live Cursors with Next.js, RPC and Durable Objects · Cloudflare Workers docs
description: In this tutorial, you will learn how to build a real-time Next.js app that displays the live cursor location of each connected user using Durable Objects, the Workers' built-in RPC (Remote Procedure Call) system, and the OpenNext Cloudflare adapter.
lastUpdated: 2025-04-28T14:34:23.000Z
source_url:
  html: https://635cd638.preview.developers.cloudflare.com/workers/tutorials/live-cursors-with-nextjs-rpc-do/index.md
  md: https://635cd638.preview.developers.cloudflare.com/workers/tutorials/live-cursors-with-nextjs-rpc-do/
---

In this tutorial, you will learn how to build a real-time [Next.js](https://nextjs.org/) app that displays the live cursor location of each connected user using [Durable Objects](https://developers.cloudflare.com/durable-objects/), the Workers' built-in [RPC (Remote Procedure Call)](https://developers.cloudflare.com/workers/runtime-apis/rpc/) system, and the [OpenNext](https://opennext.js.org/cloudflare) Cloudflare adapter.

The application works like this:

* An ID is generated for each user that navigates to the application, which is used for identifying the WebSocket connection in the Durable Object.
* Once the WebSocket connection is established, the application sends a message to the WebSocket Durable Object to determine the current number of connected users.
* A user can close all active WebSocket connections via a Next.js server action that uses an RPC method.
* It handles WebSocket and mouse movement events to update the location of other users' cursors in the UI and to send updates about the user's own cursor, as well as join and leave WebSocket events.

![Animated gif of real-time Next.js app for visualizing live cursors](https://developers.cloudflare.com/_astro/demo-live-cursors-nextjs-do.N59KMu3T_Z160tGh.webp)

***

## 1. Create a Next.js Workers Project

1. Run the following command to create your Next.js Worker named `next-rpc`:

   * npm

     ```sh
     npm create cloudflare@latest -- next-rpc --framework=next
     ```

   * pnpm

     ```sh
     pnpm create cloudflare@latest next-rpc --framework=next
     ```

   * yarn

     ```sh
     yarn create cloudflare next-rpc --framework=next
     ```

   For setup, select the following options:

   * For *What would you like to start with?*, choose `Framework Starter`.
   * For *Which development framework do you want to use?*, choose `Next.js`.
   * Complete the framework's own CLI wizard.
   * For *Do you want to use git for version control?*, choose `Yes`.
   * For *Do you want to deploy your application?*, choose `No` (we will be making some changes before deploying).

2. Change into your new directory:

   ```sh
   cd next-rpc
   ```

3. Install [nanoid](https://www.npmjs.com/package/nanoid) so that string IDs can be generated for clients:

   * npm

     ```sh
     npm i nanoid
     ```

   * pnpm

     ```sh
     pnpm add nanoid
     ```

   * yarn

     ```sh
     yarn add nanoid
     ```

4. Install [perfect-cursors](https://www.npmjs.com/package/perfect-cursors) to interpolate cursor positions:

   * npm

     ```sh
     npm i perfect-cursors
     ```

   * pnpm

     ```sh
     pnpm add perfect-cursors
     ```

   * yarn

     ```sh
     yarn add perfect-cursors
     ```

5. Define workspaces for each Worker:

   * npm

     Update your `package.json` file.

     ```json
     {
       "name": "next-rpc",
       "version": "0.1.0",
       "private": true,
       "scripts": {
         "dev": "next dev",
         "build": "next build",
         "start": "next start",
         "lint": "next lint",
         "deploy": "cloudflare && wrangler deploy",
         "preview": "cloudflare && wrangler dev",
         "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts"
       },
       "workspaces": [
         ".",
         "worker"
       ],
       // ...
     }
     ```

   * pnpm

     Create a new file `pnpm-workspace.yaml`.

     ```yaml
     packages:
       - "worker"
       - "."
     ```

## 2. Create a Durable Object Worker

This Worker will manage the Durable Object and also have internal APIs that will be made available to the Next.js Worker using a [`WorkerEntrypoint`](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/) class.

1. Create another Worker named `worker` inside the Next.js directory:

   * npm

     ```sh
     npm create cloudflare@latest -- worker
     ```

   * pnpm

     ```sh
     pnpm create cloudflare@latest worker
     ```

   * yarn

     ```sh
     yarn create cloudflare worker
     ```

   For setup, select the following options:

   * For *What would you like to start with?*, choose `Hello World Starter`.
   * For *Which template would you like to use?*, choose `Worker + Durable Objects`.
   * For *Which language do you want to use?*, choose `TypeScript`.
   * For *Do you want to use git for version control?*, choose `Yes`.
   * For *Do you want to deploy your application?*, choose `No` (we will be making some changes before deploying).

## 3. Build Durable Object Worker functionality

1. In your `worker/wrangler.toml` file, update the Durable Object binding:

   ```toml
   # ... Other wrangler configuration settings


   [[durable_objects.bindings]]
   name = "CURSOR_SESSIONS"
   class_name = "CursorSessions"


   [[migrations]]
   tag = "v1"
   new_sqlite_classes = ["CursorSessions"]
   ```

2. Initialize the main methods for the Durable Object and define types for WebSocket messages and cursor sessions in your `worker/src/index.ts` to support type-safe interaction:

   * `WsMessage`. Specifies the structure of WebSocket messages handled by the Durable Object.
   * `Session`. Represents the connected user's ID and current cursor coordinates.

   ```ts
   import { DurableObject } from 'cloudflare:workers';


   export type WsMessage =
     | { type: "message"; data: string }
     | { type: "quit"; id: string }
     | { type: "join"; id: string }
     | { type: "move"; id: string; x: number; y: number }
     | { type: "get-cursors" }
     | { type: "get-cursors-response"; sessions: Session[] };


   export type Session = { id: string; x: number; y: number };


   export class CursorSessions extends DurableObject<Env> {
     constructor(ctx: DurableObjectState, env: Env) {}
     broadcast(message: WsMessage, self?: string) {}
     async webSocketMessage(ws: WebSocket, message: string) {}
     async webSocketClose(ws: WebSocket, code: number) {}
     closeSessions() {}
     async fetch(request: Request) {
       return new Response("Hello");
     }
   }


   export default {
     async fetch(request, env, ctx) {
       return new Response("Ok");
     },
   } satisfies ExportedHandler<Env>;
   ```

   Now update `worker-configuration.d.ts` by running:

   ```sh
   cd worker && npm run cf-typegen
   ```

3. Update the Durable Object to manage WebSockets:

   ```ts
   // Rest of the code


   export class CursorSessions extends DurableObject<Env> {
     sessions: Map<WebSocket, Session>;


     constructor(ctx: DurableObjectState, env: Env) {
       super(ctx, env);
       this.sessions = new Map();
       this.ctx.getWebSockets().forEach((ws) => {
         const meta = ws.deserializeAttachment();
         this.sessions.set(ws, { ...meta });
       });
     }


     broadcast(message: WsMessage, self?: string) {
       this.ctx.getWebSockets().forEach((ws) => {
         const { id } = ws.deserializeAttachment();
         if (id !== self) ws.send(JSON.stringify(message));
       });
     }


     async webSocketMessage(ws: WebSocket, message: string) {
       if (typeof message !== "string") return;
       const parsedMsg: WsMessage = JSON.parse(message);
       const session = this.sessions.get(ws);
       if (!session) return;


       switch (parsedMsg.type) {
         case "move":
           session.x = parsedMsg.x;
           session.y = parsedMsg.y;
           ws.serializeAttachment(session);
           this.broadcast(parsedMsg, session.id);
           break;


         case "get-cursors":
           const sessions: Session[] = [];
           this.sessions.forEach((session) => {
             sessions.push(session);
           });
           const wsMessage: WsMessage = { type: "get-cursors-response", sessions };
           ws.send(JSON.stringify(wsMessage));
           break;


         case "message":
           this.broadcast(parsedMsg);
           break;


         default:
           break;
       }
     }


     async webSocketClose(ws: WebSocket, code: number) {
       const id = this.sessions.get(ws)?.id;
       id && this.broadcast({ type: 'quit', id });
       this.sessions.delete(ws);
       ws.close();
     }


     closeSessions() {
       this.ctx.getWebSockets().forEach((ws) => ws.close());
     }


     async fetch(request: Request) {
       const url = new URL(request.url);
       const webSocketPair = new WebSocketPair();
       const [client, server] = Object.values(webSocketPair);
       this.ctx.acceptWebSocket(server);
       const id = url.searchParams.get("id");
       if (!id) {
         return new Response("Missing id", { status: 400 });
       }


       // Set Id and Default Position
       const sessionInitialData: Session = { id, x: -1, y: -1 };
       server.serializeAttachment(sessionInitialData);
       this.sessions.set(server, sessionInitialData);
       this.broadcast({ type: "join", id }, id);


       return new Response(null, {
         status: 101,
         webSocket: client,
       });
     }
   }


   export default {
     async fetch(request, env, ctx) {
       if (request.url.match("/ws")) {
         const upgradeHeader = request.headers.get("Upgrade");
         if (!upgradeHeader || upgradeHeader !== "websocket") {
           return new Response("Durable Object expected Upgrade: websocket", {
             status: 426,
           });
         }
         const id = env.CURSOR_SESSIONS.idFromName("globalRoom");
         const stub = env.CURSOR_SESSIONS.get(id);
         return stub.fetch(request);
       }
       return new Response(null, {
         status: 400,
         statusText: "Bad Request",
         headers: {
           "Content-Type": "text/plain",
         },
       });
     },
   } satisfies ExportedHandler<Env>;
   ```

   * The main `fetch` handler routes requests with a `/ws` URL to the `CursorSessions` Durable Object where a WebSocket connection is established.

   * The `CursorSessions` class manages WebSocket connections, session states, and broadcasts messages to other connected clients.

     * When a new WebSocket connection is established, the Durable Object broadcasts a `join` message to all connected clients; similarly, a `quit` message is broadcast when a client disconnects.
     * It tracks each WebSocket client's last cursor position under the `move` message, which is broadcasted to all active clients.
     * When a `get-cursors` message is received, it sends the number of currently active clients to the specific client that requested it.

4. Extend the `WorkerEntrypoint` class for RPC:

   Note

   A service binding to `SessionsRPC` is used here because Durable Object RPC is not yet supported in multiple `wrangler dev` sessions. In this case, two `wrangler dev` sessions are used: one for the Next.js Worker and one for the Durable Object Worker. In production, however, Durable Object RPC is not an issue. For convenience in local development, a service binding is used instead of directly invoking the Durable Object RPC method.

   ```ts
   import { DurableObject } from 'cloudflare:workers';
   import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
   // ... rest of the code


   export class SessionsRPC extends WorkerEntrypoint<Env> {
     async closeSessions() {
       const id = this.env.CURSOR_SESSIONS.idFromName("globalRoom");
       const stub = this.env.CURSOR_SESSIONS.get(id);
       // Invoking Durable Object RPC method. Same `wrangler dev` session.
       await stub.closeSessions();
     }
   }


   export default {
     async fetch(request, env, ctx) {
       if (request.url.match("/ws")) {
   // ...
   ```

5. Leave the Durable Object Worker running. It's used for RPC and serves as a local WebSocket server:

   * npm

     ```sh
     npm run dev
     ```

   * pnpm

     ```sh
     pnpm run dev
     ```

   * yarn

     ```sh
     yarn run dev
     ```

6. Use the resulting address from the previous step to set the Worker host as a public environment variable in your Next.js project:

   ```text
   NEXT_PUBLIC_WS_HOST=localhost:8787
   ```

## 4. Build Next.js Worker functionality

1. In your Next.js Wrangler file, declare the external Durable Object binding and the Service binding to `SessionsRPC`:

   ```toml
   # ... rest of the configuration
   compatibility_flags = ["nodejs_compat"]


   # Minification helps to keep the Worker bundle size down and improve start up time.
   minify = true


   # Use the new Workers + Assets to host the static frontend files
   assets = { directory = ".worker-next/assets", binding = "ASSETS" }


   [[durable_objects.bindings]]
   name = "CURSOR_SESSIONS"
   class_name = "CursorSessions"
   script_name = "worker"


   [[services]]
   binding = "RPC_SERVICE"
   service = "worker"
   entrypoint = "SessionsRPC"
   ```

2. Update your `env.d.ts` file for type-safety:

   ```ts
   interface CloudflareEnv {
     CURSOR_SESSIONS: DurableObjectNamespace<
       import("./worker/src/index").CursorSessions
     >;
     RPC_SERVICE: Service<import("./worker/src/index").SessionsRPC>;
     ASSETS: Fetcher;
   }
   ```

3. Include Next.js server side logic:

   * Add a server action to close all active WebSocket connections.
   * Use the RPC method `closeSessions` from the `RPC_SERVICE` Service binding instead of invoking the Durable Object RPC method because of the limitation mentioned in the note above.
   * The server component generates unique IDs using `nanoid` to identify the WebSocket connection within the Durable Object.
   * Set the [`dynamic`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config) value to `force-dynamic` to ensure unique ID generation and avoid static rendering

   ```tsx
   import { getCloudflareContext } from "@opennextjs/cloudflare";
   import { Cursors } from "./cursor";
   import { nanoid } from "nanoid";


   export const dynamic = "force-dynamic";


   async function closeSessions() {
     "use server";
     const cf = await getCloudflareContext();
     await cf.env.RPC_SERVICE.closeSessions();


     // Note: Not supported in `wrangler dev`
     // const id = cf.env.CURSOR_SESSIONS.idFromName("globalRoom");
     // const stub = cf.env.CURSOR_SESSIONS.get(id);
     // await stub.closeSessions();
   }


   export default function Home() {
     const id = `ws_${nanoid(50)}`;
     return (
       <main className="flex min-h-screen flex-col items-center p-24 justify-center">
         <div className="border border-dashed w-full">
           <p className="pt-2 px-2">Server Actions</p>
           <div className="p-2">
             <form action={closeSessions}>
               <button className="border px-2 py-1">Close WebSockets</button>
             </form>
           </div>
         </div>
         <div className="border border-dashed w-full mt-2.5">
           <p className="py-2 px-2">Live Cursors</p>
           <div className="px-2 space-y-2">
             <Cursors id={id}></Cursors>
           </div>
         </div>
       </main>
     );
   }
   ```

4. Create a client component to manage WebSocket and mouse movement events:

   The generated ID is used here and passed as a parameter to the WebSocket server:

   ```ts
   const ws = new WebSocket(
     `${wsProtocol}://${process.env.NEXT_PUBLIC_WS_HOST}/ws?id=${props.id}`,
   );
   ```

   The component starts the WebSocket connection and handles 4 types of WebSocket messages, which trigger updates to React's state:

   * `join`. Received when a new WebSocket connection is established.
   * `quit`. Received when a WebSocket connection is closed.
   * `move`. Received when a user's cursor moves.
   * `get-cursors-response`. Received when a client sends a `get-cursors` message, which is triggered once the WebSocket connection is open.

   It sends the user's cursor coordinates to the WebSocket server during the [`mousemove`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousemove_event) event, which then broadcasts them to all active WebSocket clients.

   Although there are multiple strategies you can use together for real-time cursor synchronization (e.g., batching, interpolation, etc.), in this tutorial throttling, spline interpolation and position normalization are used:

   ```ts
   document.addEventListener(
     "mousemove",
     (ev) => {
       const x = ev.pageX / window.innerWidth,
         y = ev.pageY / window.innerHeight;
       const now = Date.now();
       if (
           now - lastSentTimestamp.current > INTERVAL &&
           wsRef.current?.readyState === WebSocket.OPEN
       ) {
         const message: WsMessage = { type: "move", id: props.id, x, y };
         wsRef.current.send(JSON.stringify(message));
         lastSentTimestamp.current = now;
         // ...
       }
     }
   );
   ```

   Each animated cursor is controlled by a `PerfectCursor` instance, which animates its position along a spline curve defined by the cursor's latest positions:

   ```ts
   // SvgCursor react component
   const refSvg = useRef<SVGSVGElement>(null);
   const animateCursor = useCallback((point: number[]) => {
     refSvg.current?.style.setProperty(
       "transform",
       `translate(${point[0]}px, ${point[1]}px)`,
     );
   }, []);
   const onPointMove = usePerfectCursor(animateCursor);
   // A `point` is added to the path whenever its vule updates;
   useLayoutEffect(() => onPointMove(point), [onPointMove, point]);
   // ...
   ```

5. Run Next.js development server:

   Note

   The Durable Object Worker must also be running.

   * npm

     ```sh
     npm run dev
     ```

   * pnpm

     ```sh
     pnpm run dev
     ```

   * yarn

     ```sh
     yarn run dev
     ```

6. Open the App in the browser.

## 5. Deploy the project

1. Change into your Durable Object Worker directory:

   ```sh
   cd worker
   ```

   Deploy the Worker:

   * npm

     ```sh
     npm run deploy
     ```

   * pnpm

     ```sh
     pnpm run deploy
     ```

   * yarn

     ```sh
     yarn run deploy
     ```

   Copy only the host from the generated Worker URL, excluding the protocol, and set `NEXT_PUBLIC_WS_HOST` in `.env.local` to this value (e.g., `worker-unique-identifier.workers.dev`).

   ```txt
   NEXT_PUBLIC_WS_HOST=localhost:8787
   NEXT_PUBLIC_WS_HOST=worker-unique-identifier.workers.dev
   ```

2. Change into your root directory and deploy your Next.js app:

   Optional Step

   Invoking Durable Object RPC methods between separate workers are fully supported in Cloudflare deployments. You can opt to use them instead of Service Bindings RPC:

   ```ts
   async function closeSessions() {
     "use server";
     const cf = await getCloudflareContext();
     await cf.env.RPC_SERVICE.closeSessions();


     // Note: Not supported in `wrangler dev`
     const id = cf.env.CURSOR_SESSIONS.idFromName("globalRoom");
     const stub = cf.env.CURSOR_SESSIONS.get(id);
     await stub.closeSessions();
   }
   ```

   * npm

     ```sh
     npm run deploy
     ```

   * pnpm

     ```sh
     pnpm run deploy
     ```

   * yarn

     ```sh
     yarn run deploy
     ```

## Summary

In this tutorial, you learned how to integrate Next.js with Durable Objects to build a real-time application to visualize cursors. You also learned how to use Workers' built-in RPC system alongside Next.js server actions. The complete code for this tutorial is available on [GitHub](https://github.com/exectx/next-live-cursors-do-rpc).

## Related resources

You can check other Cloudflare tutorials or related resources:

* [Workers RPC](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/).
* [Next.js and Workers Static Assets](https://developers.cloudflare.com/workers/frameworks/framework-guides/nextjs/).
* [Build a seat booking app with SQLite in Durable Objects](https://developers.cloudflare.com/durable-objects/tutorials/build-a-seat-booking-app/).
