Skip to contentVisit my new website at chrisnicholas.dev
Article posted on()

Live Cursors with Liveblocks & Next.js
How to display users' live cursors with Liveblocks & Next.js

Displaying other users' cursors live on-screen has always been tricky to implement... but no longer! Using Liveblocks and Next.js we can get it working in a matter of minutes. Open this page in another window, side-by-side, to see it in action!
Table of contents

Displaying other users' cursors live on-screen has always been tricky to implement... but no longer! Using Liveblocks and Next.js we can get it working in a matter of minutes. Open this page in another window, side-by-side, to see it in action!

Open in new window

What is Liveblocks?

Liveblocks is a set of APIs and tools (released just two days ago!) built to assist with creating collaborative experiences, such as shared drawing tools, shared forms, and seeing live cursors. In the past I've created live cursors with a custom websocket server, and I cannot emphasise how much easier this is!
Simulated cursors

To check out the working demo on this page, open this article in two browser windows, side-by-side, and try moving your cursor around. You should see something like the demo above!

Sign up & Install

First, we need to sign up to Liveblocks (side note: their website's design is magical). Their free plan allows for unlimited rooms, and up to 5,000 connections per month.

After verifying your email, hop into the Dashboard and make a note of your API key (it'll look similar to this):

sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

From this point you can follow along on CodeSandbox, if you don't have a project ready:

Edit on CodeSandbox
Live Cursors with Liveblocks & Next.js, initial template

Install packages

Next we need to install the Liveblocks packages to our Next.js project:

npm install @liveblocks/client @liveblocks/react @liveblocks/node

And set the Next.js LIVEBLOCKS_SECRET_KEY environment variable to the secret key:

.env.local
LIVEBLOCKS_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Liveblocks Concepts

Liveblocks makes use of a couple of concepts that we must understand, the first being a room.

Room

A room, much like real life, is a single location where users can congregate. If you're using live cursors on multiple pages in your app, it makes sense to create a new room for each page. You can also create new rooms for the same page, when the initial room becomes too busy.

Presence

Presence refers to the movements and actions of people within the rooms. In the case of our demo, presence will refer to an object held by each user, containing the locations of their cursors on the page.

Room
Live Cursors with Liveblocks & Next.jsMonday, 2 August 2021 (Monday, 11 July 2022)Display users' live cursors with Liveblocks, Next.js, & VercelDisplaying other users' cursors live on-screen has always been tricky to implement... but no longer! Using Liveblocks on Next.js & Vercel, we can get it working in a matter of minutes. Open this page in another window, side-by-side, to see it in action!Need to knowThis article has been updated for Liveblocks 0.17, and implements the latestbreaking changes.What is Liveblocks?Presencecursor: { x: 1338, y: 687,}connectionId: 26Presencecursor: { x: 254, y: 474,}connectionId: 32

Authenticating

To connect to a Liveblocks room we need to build a simple API endpoint. This will allow us to connect without exposing our secret key. Create a new file within /pages/api/ and name it auth.ts:

/pages/api/auth.ts
import { authorize } from '@liveblocks/node'

const secret = process.env.LIVEBLOCKS_SECRET_KEY as string

// Connect to Liveblocks
export default async function auth (req, res) {
  const room = req.body.room
  const result = await authorize({ room, secret })
  return res.status(result.status).end(result.body)
}

Create config

Liveblocks 0.17 brings improved TypeScript support, and the best way to leverage this is to add all our types to a special config file, providing automatic typing to every React hook. We'll call this file liveblocks.config.ts and put it in the root of our project. We'll also be connecting to our API route from in here—don't worry, I'll explain about this below!

/liveblocks.config.ts
import { createClient } from '@liveblocks/client'
import { createRoomContext } from '@liveblocks/react'

// Connect to our API route
const client = createClient({
  authEndpoint: '/api/auth'
})

// Define user presence
type Presence = {
  cursor: { x: number, y: number } | null
}

// Pass client and Presence to createRoomContext & create React utilities
export const {
  RoomProvider,
  useUpdateMyPresence,
  useOthers
} = createRoomContext<Presence>(client)

Let's break it down. createClient's authEndpoint property should point to the authentication route we created earlier:

const client = createClient({
  authEndpoint: '/api/auth'
})

Next we'll define our types. This type is the presence we'll be using in our app; cursors will either be on screen (with x and y coordinates), or be off screen (null). We can use any JSON-serialisable data in presence:

type Presence = {
  cursor: { x: number, y: number } | null
}

We then pass client and Presence to createRoomContext, and from here we can export every React hook we'll be using:

export const {
  RoomProvider,
  useUpdateMyPresence,
  useOthers
  /* Other React hooks */
} = createRoomContext<Presence>(client)

Every time you use a new React hook, remember to export it from here!

Cursor component

Now we can start developing our app. First up, we'll create a quick cursor component:

We'll be moving the cursor around using transform: translate(), along with a transition property to smooth the animation between every Liveblocks update.

Fragment from /components/Cursor.ts
// Simple arrow shape
const CursorImage = () => <svg><path fill="currentColor" d="M8.482,0l8.482,20.36L8.322,17.412,0,20.36Z" transform="translate(11 22.57) rotate(-48)" /></svg>

// Give cursor absolute x/y positioning
export default function Cursor ({ x, y }: { x: number, y: number }) {
  return (
    <div style={{
      color: 'black',
      position: 'absolute',
      transform: `translate(${x}px, ${y}px)`,
      transition: 'transform 120ms linear'
    }}>
      <CursorImage />
    </div>
  )
}

We'll build the avatar cursors later, let's hook everything up first!

Rendering the cursors

The next step is to render the cursors to the screen. For this, we're going to create a new component named CursorPresence. I'll explain in detail below, but first, here's the code:

/components/CursorPresence.ts
import { useUpdateMyPresence, useOthers } from '../liveblocks.config'
import Cursor from './Cursor'

export default function CursorPresence ({ children }) {
  const updateMyPresence = useUpdateMyPresence()

  const onPointerMove = event => {
    updateMyPresence({
      cursor: {
        x: Math.round(event.clientX),
        y: Math.round(event.clientY)
      }
    })
  }

  const onPointerLeave = () => {
    updateMyPresence({ cursor: null })
  }

  const others = useOthers()
  const showOther = ({ connectionId, presence }) => {
    if (!presence || !presence.cursor) {
      return null
    }

    const { x, y } = presence.cursor
    return (
      <Cursor key={connectionId} x={x} y={y} />
    )
  }

  return (
    <div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
      {others.map(showOther)}
      {children}
    </div>
  )
}

This'll make sense in a minute! Let's take a look.

useUpdateMyPresence

useUpdateMyPresence() is a hook that updates the current user's presence, and automagically sends out updates to other connected users. We're using it within onPointerMove to pass the current location of the cursor, and then within onPointerLeave to reset the location if the cursor leaves the <div>.

// Update your cursor locations with this function
const updateMyPresence = useUpdateMyPresence()

// When your cursor moves, update your presence with its current location
const onPointerMove = event => {
  updateMyPresence({
    cursor: {
      x: Math.round(event.clientX),
      y: Math.round(event.clientY)
    }
  })
}

// If your cursor leaves the element, or window, set cursor to null
const onPointerLeave = () => {
  updateMyPresence({ cursor: null })
}

// ...
return (
  <div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
    ...
  </div>
)

Now we're successfully updating our presence/cursor location!

useOthers

useOthers() is a hook that returns an array of each user's presence (excluding yours). We can utilise this to display other users' cursors. We'll create a function that renders the cursor to the page, so long as presence and presence.cursor are set, and call this on each user's presence.

// ...

// Get other users' presence
const others = useOthers()

// Function to display a user's presence
const showOther = ({ connectionId, presence }) => {
  // If presence or cursor null or undefined, don't display
  if (!presence?.cursor) {
    return null
  }

  // Display cursor
  const { x, y } = presence.cursor
  return (
    <Cursor key={connectionId} x={x} y={y} />
  )
}
return (
  <div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
    {others.map(showOther)}
    {children}
  </div>
)

Note that we've used children within the div; this is because we'll be placing our entire page within this component, to allow cursors to be seen across the whole page. We're also using connectedId, which is a handy unique id we use as a React key.

Export from config

Remember that we're exporting these hooks from the config file:

Fragment from /liveblocks.config.ts
export const {
  useUpdateMyPresence,
  useOthers,
  // ...
} = createRoomContext<Presence>(client)
Fragment from /components/CursorPresence.ts
import { useUpdateMyPresence, useOthers } from '../liveblocks.config'

Summing up

Here's the entire component again, with added comments:

CursorPresence.ts
import { useUpdateMyPresence, useOthers } from '../liveblocks.config'
import Cursor from './Cursor'

export default function CursorPresence ({ children }) {
  // Update your cursor locations with this
  const updateMyPresence = useUpdateMyPresence()

  // When your cursor moves, update your presence with its current location
  const onPointerMove = event => {
    updateMyPresence({
      cursor: {
        x: Math.round(event.clientX),
        y: Math.round(event.clientY)
      }
    })
  }

  // If your cursor leaves the element, or window, set presence to null
  const onPointerLeave = event => {
    updateMyPresence({ cursor: null })
  }

  // Get other users' presence
  const others = useOthers()

  // Display another's presence
  const showOther = ({ connectionId, presence }) => {
    // If presence is not set or cursor location is null, don't display
    if (!presence?.cursor) {
      return null
    }
    
    // Display cursor
    const { x, y } = presence.cursor
    return (
      <Cursor key={connectionId} x={x} y={y} />
    )
  }

  return (
    <div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
      {others.map(showOther)}
      {children}
    </div>
  )
}

On to the final step now, adding the room!

Creating the room

Each page our app will mostly likely want to have a separate room—we won't want people seeing the cursors used on other pages, and for this reason, we'll define our room at the page level. All we need to do is place our newly built CursorPresence component within RoomProvider, and pass an initial presence value.

/pages/index.ts
import { RoomProvider } from '../liveblocks.config'
import CursorPresence from '../components/CursorPresence'

export default function Index () {
  // Creating a room
  return (
    <RoomProvider id="index-room" initialPresence={{ cursor: null }}>
      <CursorPresence>
        Page content here
      </CursorPresence>
    </RoomProvider>
  )
}

Following on from earlier, the initial presence is { cursor: null }, to represent a cursor that isn't being rendered.

And that's it—we now have working live cursors! You can pass any body content inside <CursorPresence>, and our page will be rendered. Here's a complete example on CodeSandbox:

Edit on CodeSandbox
Live Cursors with Liveblocks & Next.js, complete

An extra touch

With a couple of little changes we can add some fancy cursors, and make sure that everyone sees the correct image.

Edit API

Any data passed to authorize({ userInfo }), within /pages/api/auth.ts, will persist while the user is connected to the platform. We can generate a number that represents an avatar, use this object to store it, and then pass this to each other user. This means that if a user is given avatar-3.svg, all other users will see avatar-3.svg for that user, not just a random avatar. Here's the code before & after:

Before (fragment within /pages/api/auth.ts)
const result = await authorize({ room, secret })
After (fragment within /pages/api/auth.ts)
const result = await authorize({
  room,
  secret,
  userInfo: {
    // There are 59 avatars in the set, pick a number at random
    avatarId: Math.floor(Math.random() * 58) + 1
  }
})

userInfo is also a helpful place to store usernames, and similar properties retrieved from an authentication system. This data cannot be changed once set.

Edit Cursor

Now we can add avatarId to the Cursor props, which can be used to grab a file, and style the new cursor:

Before (fragment within /components/Cursor.ts)
export default function Cursor ({ x = 0, y = 0 }) {
}
return (
  <div style={...}>
    <CursorImage />
  </div>
)
After (fragment within /components/Cursor.ts)
export default function Cursor ({ x = 0, y = 0, avatarId }) {
}
return (
  <div style={/* ... */}>
    {/* Style new cursor */}
    <img src={`/path/to/images/avatar-${avatarId}.svg`} style={...} alt="..." />
  </div>
)

Edit CursorPresence

And finally, within CursorPresence pass the id to the cursor using the info property (info will return any data stored in userInfo):

Before (fragment within /components/CursorPresence.ts)
// Function to display a user's presence
const showOther = ({ connectionId, presence }) => {
  ...
  return (
    <Cursor key={connectionId} x={x} y={y} />
  )
}
After (fragment within /components/CursorPresence.ts)
// Function to display a user's presence
const showOther = ({ connectionId, presence, info }) => {
  ...
  return (
    <Cursor key={connectionId} x={x} y={y} avatarId={info.avatarId} />
  )
}

Fancy cursors complete!

Summary

It's now amazingly easy to create collaborative experiences thanks to Liveblocks. And bear in mind that any data can be sent as presence, not just cursor positions. We've only reached the tip of the iceberg in 2021—collaboration is our future! Make sure to check out my article on animating multiplayer cursors, if you'd like to take your cursor game to the next level: