tldraw sync

You can add realtime multi-user collaboration to your tldraw app by using tldraw sync. It's our library for fast, fault-tolerant shared document syncing, and is used in production on our flagship app tldraw.com.

We offer a hosted demo of tldraw sync which is suitable for prototyping. To use tldraw sync in production, you will need to host it yourself.

Deploying tldraw sync

There are two main ways to go about hosting tldraw sync:

  1. Deploy a full backend to Cloudflare using our template (recommended).
  2. Integrate tldraw sync into your own JavaScript backend using our examples and docs as a guide.

Use our Cloudflare template

The best way to get started hosting your own backend is to clone and deploy our Cloudflare template. The template provides a production-grade minimal setup of the system that runs on tldraw.com.

It uses:

  • Durable Objects to provide a unique WebSocket server per room.
  • R2 to persist document snapshots and store large binary assets like images and videos.

There are some features that we have not provided and you might want to add yourself:

  • Authentication and authorization.
  • Rate limiting and size limiting for asset uploads.
  • Storing snapshots of documents over time for long-term history.
  • Listing and searching for rooms.

Make sure you also read the section below about deployment concerns.

Get started with the Cloudflare template.

Integrate tldraw sync into your own backend

The @tldraw/sync-core library can be used to integrate tldraw sync into any JavaScript server environment that supports WebSockets.

We have a simple server example, supporting both NodeJS and Bun, to use as a reference for how things should be stitched together.

What does a tldraw sync backend do?

A backend for tldraw sync consists of two or three parts:

  • A WebSocket server that provides rooms for each shared document, and is responsible for synchronizing and persisting document state.
  • An asset storage provider for large binary files like images and videos.
  • (If using the built-in bookmark shape) An unfurling service to extract metadata about bookmark URLs.

On the frontend, there is just one part: the sync client, created using the useSync hook from the @tldraw/sync package.

Pulling all four of these together, here's what a simple client implementation might look like:

import { Tldraw, TLAssetStore, Editor } from 'tldraw'
import { useSync } from '@tldraw/sync'
import { uploadFileAndReturnUrl } from './assets'
import { convertUrlToBookmarkAsset } from './unfurl'

function MyEditorComponent({myRoomId}) {
	// This hook creates a sync client that manages the websocket connection to the server
	// and coordinates updates to the document state.
	const store = useSync({
		// This is how you tell the sync client which server and room to connect to
		uri: `wss://my-custom-backend.com/connect/${myRoomId}`,
		// This is how you tell the sync client how to store and retrieve blobs
		assets: myAssetStore,
	})
	// When the tldraw Editor mounts, you can register an asset handler for the bookmark URLs
	return <Tldraw store={store} onMount={registerUrlHandler} />
}

const myAssetStore: TLAssetStore {
	upload(file, asset) {
		return uploadFileAndReturnUrl(file)
	},
	resolve(asset) {
		return asset.props.src
	},
}

function registerUrlHandler(editor: Editor) {
	editor.registerExternalAssetHandler('url', async ({url}) => {
		return await convertUrlToBookmarkAsset(url)
	})
}

And here's a full working example of the client-side code.

WebSocket server

The @tldraw/sync-core package exports a class called TLSocketRoom that should be created server-side on a per-document basis.

TLSocketRoom is used to

  • Store an authoritative in-memory copy of the document state
  • Transparently set up communication between multiple sync clients via WebSockets.
  • Provide hooks for persisting the document state when it changes.

You should make sure that there's only ever one TLSocketRoom globally for each room in your app. If there's more than one, users won't see each other and will overwrite others' changes. We use Durable Objects to achieve this on tldraw.com.

Read the reference docs for TLSocketRoom, and see an example of how to use it in the simple server example.

Asset storage

As well as synchronizing the rapidly-changing document data, tldraw also needs a way to store and retrieve large binary assets like images or videos.

You'll need to make sure your backend can handle asset uploads & downloads, then implement TLAssetStore to connect it to tldraw.

Unfurling service

If you want to use the built-in bookmark shape, you'll need to use or implement an unfurling service that returns metadata about URLs.

This should be registered with the Editor when it loads.

<Tldraw
	store={store}
	onMount={(editor) => {
		editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
	}}
/>

Refer to the simple server example for example client and server code.

Using tldraw sync in your app

Custom shapes & bindings

@tldraw/sync validates the contents of your document and runs migrations to make sure clients of different versions can collaborate without issue. To support this, you need to make sure that both the sync client and server know about any custom shapes or bindings you've added.

On the client

You can pass shapeUtils and bindingUtils props to useSync. Unlike <Tldraw />, these don't automatically include tldraw's default shapes like arrows and rectangles. You should pass those in explicitly if you're using them:

import { Tldraw, defaultShapeUtils, defaultBindingUtils } from 'tldraw'
import { useSync } from '@tldraw/sync'

function MyApp() {
	const store = useSync({
		uri: '...',
		assets: myAssetStore,
		shapeUtils: useMemo(() => [...customShapeUtils, ...defaultShapeUtils], []),
		bindingUtils: useMemo(() => [...customBindingUtils, ...defaultBindingUtils], []),
	})

	return <Tldraw store={store} shapeUtils={customShapeUtils} bindingUtils={customBindingUtils} />
}

On the server

Use createTLSchema to create a store schema, and pass that into TLSocketRoom. You can use shape/binding utils here, but schema will only look at two properties: props and migrations. You need to provide the default shape schemas if you're using them.

import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema'
import { TLSocketRoom } from '@tldraw/sync-core'

const schema = createTLSchema({
	shapes: {
		...defaultShapeSchemas,

		myCustomShape: {
			// validations for this shapes `props`
			props: myCustomShapeProps,
			// migrations between versions of this shape
			migrations: myCustomShapeMigrations,
		},

		// the schema knows about this shape, but it has no migrations or validation
		mySimpleShape: {},
	},
	bindings: defaultBindingSchemas,
})

// later, in your app server:
const room = new TLSocketRoom({
	schema: schema,
	// ...
})

Both props and migration are optional. If you omit props, you won't have any server-side validation for your shape, which could result in bad data being stored. If you omit migrations, clients on different versions won't be able to collaborate without errors.

Deployment concerns

You must make sure that the tldraw version in your client matches the version on the server. We don't guarantee server backwards compatibility forever, and very occasionally we might release a version where the backend cannot meaningfully support older clients, in which case tldraw will display a "please refresh the page" message. So you should make sure that the backend is updated at the same time as the client, and that the new backend is up and running just before the new client rolls out.

Migrating data from a legacy system

If you have been using some other solution for data sync, you can migrate your existing data to the tldraw sync format.

TLSocketRoom supports loading TLStoreSnapshot snapshots, so in your data loading code you can add a backwards-compatibility layer that lazily imports data from your old system and converts it to a TLStoreSnapshot.

Something like

import { TLSocketRoom } from '@tldraw/sync-core'

async function loadOrMakeRoom(roomId: string) {
	const data = await loadRoomDataFromCurrentStore(roomId)
	if (data) {
		return new TLSocketRoom({ initialSnapshot: data })
	}
	const legacyData = await loadRoomDataFromLegacyStore(roomId)
	if (legacyData) {
		// convert your old data to a TLStoreSnapshot
		const snapshot = convertOldDataToSnapshot(legacyData)
		// load it into the room
		const room = new TLSocketRoom({ initialSnapshot: snapshot })
		// save an updated copy of the snapshot in the new place
		// so that next time we can load it directly
		await saveRoomData(roomId, room.getCurrentSnapshot())
		// optionally delete the old data
		await deleteLegacyRoomData(roomId)
		// and finally return the room
		return room
	}
	// if there's no data at all, just make a new blank room
	return new TLSocketRoom()
}
Prev
Collaboration
Next
Translations