- Notifications
You must be signed in to change notification settings - Fork2
A starter repo for a Jamsocket tutorial using NextJS
jamsocket/jamsocket-nextjs-tutorial
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
This repo is a starter repo for the Jamsocket NextJS + Socket.io Tutorial. The tutorial walks through how to use Jamsocket and Socket.io to add multiplayer presence and state-sharing features to a NextJS whiteboard app.
To try out a completed version of the whiteboard from this tutorial, check out thecompleted
branch, runnpm install
andnpm run dev
. Then runnpx jamsocket dev
in a separate terminal tab and visithttp://localhost:3000
.
Session backends are great for document-editing apps, which often load the entire document into memory and apply changes to the in-memory document. (Here, a document may be a text document, spreadsheet, vector graphic, image, or video, etc). For these kinds of applications, the session backend acts as a stateful layer between your client and storage (i.e. a database or blob store). The session backend is a place to...
- quickly handle edits to the in-memory document
- persist changes to your storage of choice in the background
This makes it easier to support collaborative editing features and synchronize your document state between several users and a backing store. Using a session backend to update an in-memory document during the user session also lets you use inexpensive blob storage (like S3) when the document is not being edited. (Read more about session backends inour blogpost about them.)
Let's see how this can work by building a whiteboard app with multiplayer editing and presence features. Here's a little preview of what we're building in this tutorial:
Note: we'll be usingDocker's command-line tool andNodeJS, so get those installed if you haven't already.
We've put togethera starter repo for this tutorial that contains a NextJS app along with a few helper functions.
git clone https://github.com/jamsocket/jamsocket-nextjs-tutorial.gitcd jamsocket-nextjs-tutorial
In the project, you'll find a typical NextJS directory structure. We'll add code to the following three files:
src/app/page.tsx
- This is what gets rendered for the/
path. For this tutorial, it'll be a React Server Component. We'll have it be responsible for starting up a session backend on Jamsocket.src/components/Home.tsx
- This is the main client-side component for our app. It is rendered by the Server Component insrc/app/page.tsx
and will be responsible for the bulk of the app's functionality.src/session-backend
- This directory contains the session backend logic. For this demo, the session backend will just be running aSocket.IO server that holds the state of the document in memory and receives/pushes document updates to/from the users who are currently editing it.
This repo also includes some helper components to kickstart our multiplayer demo:
src/components/Whiteboard.tsx
- This is a simple little whiteboard component that encapsulates the canvas and all the logic for creating and updating shapes from user interactions and for drawing other users' cursors to the screen.src/components/Content.tsx
,src/components/Header.tsx
- Some helper components for the styled elements of the demo.
Once you've cloned the repo, run:
npm installnpm run dev
Then you should be able to open the app in your browser on localhost with the port shown in the command output (probablyhttp://localhost:3000
).
If everything works, you'll notice you can create shapes by clicking and dragging on the page. You can move existing shapes by dragging them.
At this point, you're probably wondering if "whiteboard" isn't overselling it a bit. And you would be right. Implementing a full whiteboard application will be an exercise left to the reader, but, for now, this very limited whiteboard should serve us well as a demo for implementing state sharing and presence features with session backends.
Speaking of state-sharing and presence features - you'll notice that opening the app in another tab gives you a completely blank canvas. Let's see if we can't get this app to share the state of the whiteboard with other tabs.
Let's start by adding presence to our application. When another user enters the document, we want to see their cursor on the canvas and their avatar up in corner of the screen.
Since the session backend will be our source of truth for the document state, let's start there.
Insrc/session-backend/index.ts
we're already importingsocket.io
, starting a WebSocket server on port 8080, and listening for new connections. Let's add some code that keeps track of which users are currently connected and emit an event to all the clients when a user connects or disconnects.
constusers:Set<{id:string;socket:Socket}>=newSet()io.on('connection',(socket:Socket)=>{console.log('New user connected:',socket.id)// store each user's socket connection and user id (socket.id will be the stand in for user id)constnewUser={id:socket.id, socket}users.add(newUser)// send all existing users a 'user-entered' event for the new usersocket.broadcast.emit('user-entered',newUser.id)// send the new user a 'user-entered' event for each existing userfor(constuserofusers){newUser.socket.emit('user-entered',user.id)}// when a user disconnects, delete the user from our set// and broadcast a 'user-exited' event to all the other userssocket.on('disconnect',()=>{users.delete(newUser)socket.broadcast.emit('user-exited',newUser.id)})})
Now that we've got a simple backend written, it's time to shift our focus to the application code for our NextJS project.
We need to do two things:
- get our server component to spawn a new backend when someone opens the whiteboard, and
- update our client-side logic to connect to the session backend and listen for our
user-entered
anduser-exited
WebSocket events.
In our Page component, let's import@jamsocket/server
. It contains helper functions that we can use to spawn a session backend. It's important that we spawn from server code as eventually we'll be using an API token here that we want to keep secret, so let's use our React Server Component (src/app/page.tsx
). (If you aren't using React Server Components, this could just as easily be done in an API route.)
import'server-only'import{Jamsocket}from'@jamsocket/server'constjamsocket=newJamsocket({dev:true})
When developing locally with the Jamsocket Dev CLI, we can just pass{ dev: true }
to theJamsocket
constructor. We'll replace this with account and service names and an API token when it comes time to deploy this to Jamsocket. You can see an example inin the@jamsocket/server
docs.
The returnedjamsocket
instance has aconnect()
method that we'll use to get a connection URL for connecting to the our session backend from a browser. It takes a single, optionalconnectRequest
argument. TheconnectRequest
object allows us to configure a lot of aspects of how the session backend runs. (Our docs have more information aboutconnect() options for the HTTP API.) For now, we will only use one of those options:key
. You can learn more about keyshere, but for now it suffices to say that we'll just use a document name. And for this demo, we'll just have one document that everybody edits calledwhiteboard-123
.
The result of thejamsocket.connect()
function contains aConnection URL that you can use to connect to the session backend, a status URL which returns the current status of the session backend, and some other values like the backend's ID.
Note thatPage
is rendered in a server-side component. This ensures that your secrets aren't leaked to the client. Once we receive the spawn result, thePage
component will pass that information to theHomeContainer
component.
import'server-only'import{Jamsocket}from'@jamsocket/server'constWHITEBOARD_NAME='whiteboard-123'constjamsocket=newJamsocket({dev:true})exportdefaultasyncfunctionPage(){constconnectResponse=awaitjamsocket.connect({key:WHITEBOARD_NAME})return<HomeContainerconnectResponse={connectResponse}/>}
To connect to our session backend, theHomeContainer
component should acceptconnectResponse
as props and pass that into theSessionBackendProvider
. TheSessionBackendProvider
lets us use Jamsocket's React hooks to interact with the session backend.
You will also need theSocketIOProvider
to connect to the SocketIO server running in your session backend. TheSocketIOProvider
uses theconnection url fromconnectResponse.url
to connect to the SocketIO server. TheSocketIOProvider
also lets us use Socket.io-specific React hooks in@jamsocket/socketio
to send and listen to events. Because@jamsocket/socketio
re-exports@jamsocket/react
's exports, we can import everything we need from@jamsocket/socketio
.
import{SessionBackendProvider,SocketIOProvider}from'@jamsocket/socketio'importtype{ConnectResponse}from'@jamsocket/socketio'exportdefaultfunctionHomeContainer({ connectResponse}:{connectResponse:ConnectResponse}){return(<SessionBackendProviderconnectResponse={connectResponse}><SocketIOProviderurl={connectResponse.url}><Home/></SocketIOProvider></SessionBackendProvider>)}
Next, let's keep track of which users are in the document with some component state. And we can pass that list of users to ourAvatarList
component which will render an avatar in the header for each user who is currently in the document.
importtype{Shape,User}from'../types'import{AvatarList}from'./Whiteboard'// ...functionHome(){constready=true// we'll replace this with a real check laterconst[shapes,setShapes]=useState<Shape[]>([])const[users,setUsers]=useState<User[]>([])return(<main><Header><AvatarListusers={users}/></Header><Content>// ...)}
Now, in ourHome
component, we can use theuseEventListener
hook to listen for ouruser-entered
anduser-exited
events we're sending from our session backend.
import{SessionBackendProvider,SocketIOProvider,useEventListener}from'@jamsocket/socketio'importtype{ConnectResponse}from'@jamsocket/socketio'
Then we can subscribe to the events with our hook. On theuser-entered
event, we should create a user object with anid
and acursorX
andcursorY
property (we'll use these when we implement cursor presence). And on theuser-exited
event, let's just remove the user from the list of users in our component state.
functionHome(){constready=true// we'll replace this with a real check laterconst[shapes,setShapes]=useState<Shape[]>([])const[users,setUsers]=useState<User[]>([])useEventListener<string>('user-entered',(id)=>{constnewUser={cursorX:null,cursorY:null, id}setUsers((users)=>[...users,newUser])})useEventListener<string>('user-exited',(id)=>{setUsers((users)=>users.filter((p)=>p.id!==id))})// ...}
Let's also import theuseReady
hook that we can use to show a spinner while the session backend is starting up. Depending on your application, it may or may not make sense to show a spinner, but for this demo we'll take the simpler approach of ensuring the session backend is running and the inital document state is loaded before the user can start editing it.
import{SessionBackendProvider,SocketIOProvider,useEventListener,useReady}from'@jamsocket/socketio'importtype{ConnectResponse}from'@jamsocket/socketio'// ...functionHome(){constready=useReady()const[shapes,setShapes]=useState<Shape[]>([])// ...}
Finally - the moment of truth. Let's start the Jamsocket Dev CLI to see if everything works! In another terminal window:
npx jamsocket dev
The dev CLI does several things to make development easier, the first of which is automatically rebuilding our session backend Docker image when the code changes. When you runnpx jamsocket dev
, the first thing it does is build your session backend code and start a local server that emulates Jamsocket's API.
Let's take a quick look at thejamsocket.config.json
file in the project root to see how all this works:
{"dockerfile":"./src/session-backend/Dockerfile","watch": ["./src/session-backend"],"dockerOptions": {"path":"." }}
This config file is used by the dev CLI so it knows (1) how to build the session backend into a Docker image and (2) which parts of the file system to watch for changes.
So in our demo, the dev CLI will watch thesrc/session-backend
directory, and when a change is detected, it will rebuild the image using the given Dockerfile and the current working directory as the Docker build context. Then, when we refresh the page, thejamsocket.connect()
function will send a request to the dev server which will spawn a new backend using the Docker container that was just built and return a connection URL for the backend.
The second thing the dev CLI does for us is keep track of session backends we've spawned during development, terminating backends that are running old code, and streaming status updates and logs from your session backend.
Now with both Jamsocket dev CLI andnpm run dev
running in separate terminal windows, you should be able to refresh the page and see an avatar in the header. And if you open the app in another window, another avatar should appear.
If you take a look at the terminal window running the dev CLI, you should see that our server component spawned a backend and now its statuses and logs are appearing in the dev CLI output.
Most of the hard work is behind us, so let's add a few more events. Let's keep track of the cursor position for each user so we can display that on top of the whiteboard.
We'll start by subscribing to acursor-position
event and updating our list of users with the user passed to it:
functionHome(){constready=useReady()const[shapes,setShapes]=useState<Shape[]>([])const[users,setUsers]=useState<User[]>([])useEventListener<User>('cursor-position',(user)=>{setUsers((users)=>users.map((p)=>p.id===user.id ?user :p))})// ...}
Then we need to send acursor-position
event to the session backend as our cursor moves over the whiteboard.
We can do this by importing theuseSend
hook and then creating asendEvent
function with it:
import{SessionBackendProvider,SocketIOProvider,useEventListener,useReady,useSend}from'@jamsocket/socketio'importtype{ConnectResponse}from'@jamsocket/socketio'functionHome(){constready=useReady()constsendEvent=useSend()const[shapes,setShapes]=useState<Shape[]>([])const[users,setUsers]=useState<User[]>([])// ...}
Then, we can pass ausers
prop and anonCursorMove
prop to our<Whiteboard>
component, that takes the cursor's position and sends it to our session backend.
<Whiteboardshapes={shapes}users={users}onCursorMove={(position)=>{sendEvent('cursor-position',{x:position?.x,y:position?.y})}}/>
Now we just need to add acursor-position
event to our session backend code. In oursrc/session-backend/index.ts
file let's subscribe to thecursor-position
event and emit acursor-position
event to all connected clients. We can usevolatile.broadcast
here because it's okay if we drop a couplecursor-position
events here and there. For cursor positions, we really just care about the most recent cursor position message.
io.on('connection',(socket:Socket)=>{console.log('New user connected:',socket.id)socket.on('cursor-position',({ x, y})=>{socket.volatile.broadcast.emit('cursor-position',{id:socket.id,cursorX:x,cursorY:y})})// ...})
Okay, with that, let's take a look at our dev CLI. If it's still running, it should have rebuilt and pushed our session backend code to Jamsocket. It should have also terminated any previous backends running with out of date code.
Now, if we open the application in a new browser window, we should see a new session backend spawning in the dev CLI. If everything works, moving your cursor over one canvas should show a moving cursor on the other client. However, the shapes you create in one window don't appear in the other. Let's fix that in the next section by implementing state sharing across clients.
The last thing we want to do in this demo is implement state-sharing. Right now, when you refresh the page, you lose all the shapes you've drawn. And when another connected client draws shapes, you can't see them. Let's fix that.
This time, we'll start with our session backend code. Let's create an array to store all the shapes. When a new user connects, we'll send them a snapshot of all the shapes. Let's also listen for two new events:create-shape
andupdate-shape
, which will update our list of shapes accordingly.
importtype{Shape}from'../types'// ...constshapes:Shape[]=[]io.on('connection',(socket:Socket)=>{console.log('New user connected:',socket.id)socket.emit('snapshot',shapes)socket.on('cursor-position',({ x, y})=>{socket.volatile.broadcast.emit('cursor-position',{id:socket.id,cursorX:x,cursorY:y})})socket.on('create-shape',(shape:Shape)=>{shapes.push(shape)socket.broadcast.emit('snapshot',shapes)})socket.on('update-shape',(updatedShape:Shape)=>{constshape=shapes.find(s=>s.id===updatedShape.id)if(!shape)returnshape.x=updatedShape.xshape.y=updatedShape.yshape.w=updatedShape.wshape.h=updatedShape.hsocket.broadcast.emit('update-shape',shape)})})
Now, let's add oursendEvent()
anduseEventListener()
calls to theHome
component.
First, we should listen for our newsnapshot
andupdate-shape
events:
functionHome(){constready=useReady()const[shapes,setShapes]=useState<Shape[]>([])const[users,setUsers]=useState<User[]>([])useEventListener<Shape[]>('snapshot',(shapes)=>{setShapes(shapes)})useEventListener<Shape>('update-shape',(shape)=>{setShapes((shapes)=>{constshapeToUpdate=shapes.find((s)=>s.id===shape.id)if(!shapeToUpdate)return[...shapes,shape]returnshapes.map((s)=>s.id===shape.id ?{ ...s, ...shape} :s)})})// ...}
Then in ouronCreateShape
andonUpdateShape
Whiteboard props, we should send the appropriate event to the session backend:
<Whiteboardshapes={shapes}users={users}onCursorMove={(position)=>{sendEvent('cursor-position',{x:position?.x,y:position?.y})}}onCreateShape={(shape)=>{sendEvent('create-shape',shape)setShapes([...shapes,shape])}}onUpdateShape={(id,shape)=>{sendEvent('update-shape',{ id, ...shape})setShapes((shapes)=>shapes.map((s)=>s.id===id ?{ ...s, ...shape} :s))}}/>
Now, the dev CLI should have rebuilt the session backend docker image and removed old session backends we had spawned with the previous version of the code. We should be able to simply open the app in a few browser windows and see:
- an avatar for each user
- each user's cursor as it hovers over the whiteboard
- all the same shapes as they are created and moved around the screen
The last thing you might want to do is actually run your session backends on Jamsocket. To do that, you'll need to:
- Create a Jamsocket account by going toapp.jamsocket.com.
- Log in to the Jamsocket CLI and create a service, let's call it
whiteboard-demo
:
npx jamsocket loginnpx jamsocket service create whiteboard-demo
- Build and push your session backend to Jamsocket:
npx jamsocket push whiteboard-demo -f src/session-backend/Dockerfile
- Create an API token onthe Jamsocket settings page.
- Change the
new Jamsocket()
call insrc/app/page.tsx
by passing inaccount
,service
, andtoken
:
constjamsocket=newJamsocket({account:'[YOUR ACCOUNT NAME]',// if you are unsure, you can find this at https://app.jamsocket.com/settingsservice:'whiteboard-demo',token:'[YOUR API TOKEN]',// this is the token you just created in step 5})
Now, when you run your NextJS app locally, it'll spawn your session backend on Jamsocket. You can see which session backends have been spawned by visiting your newwhiteboard-demo
service inthe Jamsocket Dashboard or by running:
npx jamsocket backend list
- Learn abouthow to persist your document state when a session backend stops.
If you have any questions about how to use Jamsocket or would like to talk through your particular use case, we'd love to chat! Send us an email athi@jamsocket.com!