Documentation Index Fetch the complete documentation index at: https://mintlify.com/tldraw/tldraw/llms.txt
Use this file to discover all available pages before exploring further.
Setting up multiplayer
This guide covers production setup with your own server infrastructure. For quick prototyping, see the quick start guide .
Overview
A production multiplayer setup requires:
Client setup - React components using useSync
Server setup - WebSocket server with TLSocketRoom
Storage layer - Persistence using SQLite, PostgreSQL, or custom storage
Asset storage - External storage for images and files
Client setup
Use the useSync hook to connect to your server:
import { Tldraw } from 'tldraw'
import { useSync } from '@tldraw/sync'
import 'tldraw/tldraw.css'
function MyApp ({ roomId } : { roomId : string }) {
const store = useSync ({
// Your WebSocket server URL
uri: `wss://sync.example.com/connect/ ${ roomId } ` ,
// Asset storage (required)
assets: {
async upload ( asset , file ) {
const formData = new FormData ()
formData . append ( 'file' , file )
const response = await fetch ( '/api/upload' , {
method: 'POST' ,
body: formData
})
const { url } = await response . json ()
return { src: url }
},
resolve ( asset ) {
return asset . props . src
}
},
// User information
userInfo: {
id: 'user-123' ,
name: 'Alice' ,
color: '#ff0000'
}
})
if ( store . status === 'loading' ) {
return < div > Loading... </ div >
}
if ( store . status === 'error' ) {
return < div > Error: { store . error . message } </ div >
}
return < Tldraw store = { store . store } />
}
Server setup
Basic WebSocket server
Create a WebSocket server using TLSocketRoom:
import { WebSocketServer } from 'ws'
import { TLSocketRoom } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'
const wss = new WebSocketServer ({ port: 8080 })
const rooms = new Map < string , TLSocketRoom < any , any >>()
wss . on ( 'connection' , ( ws , req ) => {
const url = new URL ( req . url ! , `ws:// ${ req . headers . host } ` )
const roomId = url . pathname . split ( '/' ). pop () !
// Get or create room
let room = rooms . get ( roomId )
if ( ! room ) {
room = new TLSocketRoom ({
schema: createTLSchema (),
onSessionRemoved : ( room , { numSessionsRemaining }) => {
if ( numSessionsRemaining === 0 ) {
room . close ()
rooms . delete ( roomId )
}
}
})
rooms . set ( roomId , room )
}
// Connect client to room
const sessionId = url . searchParams . get ( 'sessionId' ) !
room . handleSocketConnect ({
sessionId ,
socket: ws ,
meta: { userId: 'anonymous' },
isReadonly: false
})
})
With Express
Integrate with an Express server:
import express from 'express'
import { WebSocketServer } from 'ws'
import { TLSocketRoom } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'
const app = express ()
const server = app . listen ( 8080 )
const wss = new WebSocketServer ({ server })
const rooms = new Map < string , TLSocketRoom < any , any >>()
wss . on ( 'connection' , ( ws , req ) => {
// ... same as above
})
app . get ( '/health' , ( req , res ) => {
res . json ({ status: 'ok' , rooms: rooms . size })
})
Storage options
Choose a storage backend for persistence.
In-memory (development only)
import { InMemorySyncStorage } from '@tldraw/sync-core'
const room = new TLSocketRoom ({
schema: createTLSchema (),
storage: new InMemorySyncStorage ()
})
In-memory storage loses all data when the server restarts. Only use for development.
SQLite (recommended)
import { SQLiteSyncStorage , NodeSqliteWrapper } from '@tldraw/sync-core'
import Database from 'better-sqlite3'
const db = new Database ( './rooms.db' )
const sql = new NodeSqliteWrapper ( db )
const room = new TLSocketRoom ({
schema: createTLSchema (),
storage: new SQLiteSyncStorage ({ sql })
})
Cloudflare Durable Objects
import {
SQLiteSyncStorage ,
DurableObjectSqliteSyncWrapper
} from '@tldraw/sync-core'
export class MyDurableObject extends DurableObject {
async fetch ( request : Request ) {
const sql = new DurableObjectSqliteSyncWrapper ( this . ctx . storage )
const room = new TLSocketRoom ({
schema: createTLSchema (),
storage: new SQLiteSyncStorage ({ sql })
})
// Handle WebSocket upgrade
// ...
}
}
Custom storage
Implement the TLSyncStorage interface:
import { TLSyncStorage } from '@tldraw/sync-core'
class PostgresStorage implements TLSyncStorage < TLRecord > {
async transaction < T >( fn : ( txn : TLSyncStorageTransaction < TLRecord >) => T ) {
// Implement transaction logic with PostgreSQL
}
onChange ( callback : () => void ) {
// Subscribe to external changes
return () => { /* cleanup */ }
}
}
const room = new TLSocketRoom ({
schema: createTLSchema (),
storage: new PostgresStorage ()
})
Asset storage
Handle file uploads with external storage:
S3-compatible storage
import { S3Client , PutObjectCommand } from '@aws-sdk/client-s3'
import { useSync } from '@tldraw/sync'
const s3 = new S3Client ({ region: 'us-east-1' })
const store = useSync ({
uri: 'wss://sync.example.com/room' ,
assets: {
async upload ( asset , file ) {
const key = `assets/ ${ Date . now () } - ${ file . name } `
await s3 . send ( new PutObjectCommand ({
Bucket: 'my-bucket' ,
Key: key ,
Body: file ,
ContentType: file . type
}))
return {
src: `https://my-bucket.s3.amazonaws.com/ ${ key } `
}
},
resolve ( asset ) {
return asset . props . src
}
}
})
Cloudflare R2
const store = useSync ({
uri: 'wss://sync.example.com/room' ,
assets: {
async upload ( asset , file ) {
const formData = new FormData ()
formData . append ( 'file' , file )
const response = await fetch ( '/api/upload-to-r2' , {
method: 'POST' ,
body: formData
})
const { url } = await response . json ()
return { src: url }
},
resolve ( asset ) {
return asset . props . src
}
}
})
Authentication
Add authentication to protect rooms:
Client-side token
const store = useSync ({
uri : async () => {
// Get auth token
const token = await getAuthToken ()
return `wss://sync.example.com/room?token= ${ token } `
},
assets: myAssetStore
})
Server-side validation
import jwt from 'jsonwebtoken'
wss . on ( 'connection' , ( ws , req ) => {
const url = new URL ( req . url ! , `ws:// ${ req . headers . host } ` )
const token = url . searchParams . get ( 'token' )
try {
const user = jwt . verify ( token ! , process . env . JWT_SECRET ! )
room . handleSocketConnect ({
sessionId: url . searchParams . get ( 'sessionId' ) ! ,
socket: ws ,
meta: { userId: user . id },
isReadonly: false
})
} catch ( error ) {
ws . close ( 4403 , 'Invalid token' )
}
})
See the authentication guide for more details.
Schema management
Use a consistent schema across client and server:
// shared/schema.ts
import { createTLSchema } from '@tldraw/tlschema'
export const mySchema = createTLSchema ()
// client.tsx
import { mySchema } from './shared/schema'
const store = useSync ({
uri: 'wss://sync.example.com/room' ,
schema: mySchema ,
assets: myAssetStore
})
// server.ts
import { mySchema } from './shared/schema'
const room = new TLSocketRoom ({
schema: mySchema ,
storage: myStorage
})
Error handling
Handle sync errors gracefully:
import { TLSyncErrorCloseEventReason } from '@tldraw/sync'
function MyApp () {
const [ error , setError ] = useState < string | null >( null )
const store = useSync ({
uri: 'wss://sync.example.com/room' ,
assets: myAssetStore ,
// onSyncError is internal, handle via store.status === 'error'
})
if ( store . status === 'error' ) {
const errorMsg = store . error . message
// Handle specific error types
if ( errorMsg . includes ( 'NOT_FOUND' )) {
return < div > Room not found </ div >
}
if ( errorMsg . includes ( 'FORBIDDEN' )) {
return < div > Access denied </ div >
}
if ( errorMsg . includes ( 'CLIENT_TOO_OLD' )) {
return < div > Please refresh to update your app </ div >
}
return < div > Connection error: { errorMsg } </ div >
}
// ...
}
Next steps
Authentication Add user authentication and access control
Deployment Deploy your sync server to production
Customization Customize sync behavior and presence
Client API Complete client API reference