Fix: tldraw Not Working — Store Persistence, Custom Shapes, Sync, and Asset Uploads
Quick Answer
How to fix tldraw errors — Tldraw component blank screen, persistenceKey vs server sync, custom shape util, asset upload handler, undo/redo, dark mode, snapshot vs store load, and tldraw sync server.
The Error
You drop in <Tldraw /> and the canvas is blank:
import { Tldraw } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";
export default function App() {
return <div style={{ position: "fixed", inset: 0 }}><Tldraw /></div>;
}
// Renders a 0x0 canvas.Or your custom shape doesn’t render:
class MyShapeUtil extends ShapeUtil<MyShape> {
static type = "my-shape" as const;
component(shape: MyShape) {
return <div>Hello</div>;
}
}
<Tldraw shapeUtils={[MyShapeUtil]} />
// Creating the shape adds nothing to the canvas.Or persistence loads stale data:
<Tldraw persistenceKey="my-board" />
// User reloads → sees data from 3 hours ago, not current state.Or pasted images don’t upload to your server:
[paste image] → image appears in canvas → on save, it's a data URL, not a server URL.Why This Happens
tldraw is a React canvas library backed by a reactive store (the Editor instance). Most issues map to:
- CSS and sizing. The
Tldrawcomponent fills its parent. Without a sized parent (position: fixed; inset: 0, or explicit width/height), it renders 0x0. - Shape utilities. Custom shapes need a
ShapeUtilsubclass implementingcomponent,indicator,getDefaultProps, etc. Without all required methods, shapes don’t render. persistenceKeyis local. It stores in IndexedDB by that key. Same key across users on the same machine = shared data. For multi-user, use sync server instead.- Asset handling is pluggable. Pasted/dropped images become “assets” in the store. The default uploads to a data URL; for server storage, provide
onCreateAssetFromFile.
Fix 1: Set Up the Component With Proper Sizing
import { Tldraw } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";
export default function App() {
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw />
</div>
);
}The parent must have a defined size. Common patterns:
- Full screen:
position: fixed; inset: 0. - Inline:
width: 800px; height: 600px;. - Flex container: parent has
display: flex; flex: 1;.
Don’t forget the CSS import — tldraw’s styles include all the UI elements. Without them, you see blank canvas with no toolbar.
For SSR (Next.js App Router):
"use client";
import { Tldraw } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";
export default function BoardClient() {
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw />
</div>
);
}tldraw is a Client Component — it uses browser-only APIs (canvas, IndexedDB, pointer events). Wrap in a Client boundary.
Pro Tip: For embedded usage (smaller than full screen), wrap in a sized div and pass forceMobile={false}. The default is responsive but may show the mobile UI in narrow containers.
Fix 2: Persistence via persistenceKey
For client-side persistence (single user, same browser):
<Tldraw persistenceKey="my-board" />This saves the entire store to IndexedDB under the key tldraw-my-board. Reloads restore the previous state.
For multiple boards in the same app:
function BoardPage({ boardId }: { boardId: string }) {
return <Tldraw persistenceKey={`board-${boardId}`} />;
}Each boardId has its own IndexedDB entry.
To clear:
import { clear } from "@tldraw/tldraw/store";
await clear(); // Clears all tldraw IndexedDB data.Or via DevTools:
Application tab → IndexedDB → tldraw-* databases → deleteCommon Mistake: Using the same persistenceKey for “save my work” and “load a specific shared document.” The persistence layer is per-browser. For shared boards, use sync (Fix 5).
Fix 3: Custom Shapes
Define a ShapeUtil subclass:
import {
ShapeUtil,
TLBaseShape,
HTMLContainer,
Rectangle2d,
Geometry2d,
} from "@tldraw/tldraw";
type MyShape = TLBaseShape<
"my-shape",
{
w: number;
h: number;
text: string;
}
>;
class MyShapeUtil extends ShapeUtil<MyShape> {
static type = "my-shape" as const;
getDefaultProps(): MyShape["props"] {
return { w: 200, h: 100, text: "Hello" };
}
getGeometry(shape: MyShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
});
}
component(shape: MyShape) {
return (
<HTMLContainer
style={{
width: shape.props.w,
height: shape.props.h,
background: "lightblue",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{shape.props.text}
</HTMLContainer>
);
}
indicator(shape: MyShape) {
return <rect width={shape.props.w} height={shape.props.h} />;
}
}
<Tldraw shapeUtils={[MyShapeUtil]} />Five required methods:
type— string identifier.getDefaultProps— initial values when a new shape is created.getGeometry— hit-testing and selection bounds.component— what renders inside the shape.indicator— the selection outline (SVG element).
To create a shape programmatically:
editor.createShape({
type: "my-shape",
x: 100,
y: 100,
props: { w: 200, h: 100, text: "Hi" },
});Common Mistake: Missing indicator. Selection looks broken — no outline appears. Always implement it.
For interactive shapes (drag handles, editable text), override canEdit, canResize, and use useEditor() inside the component for state changes.
Fix 4: Asset Upload Handler
By default, pasted images become base64 data URLs in the store — bloats your snapshot. To upload to a server:
import { Tldraw, type TLAssetStore } from "@tldraw/tldraw";
const assetStore: TLAssetStore = {
async upload(asset, file) {
// file is a Blob/File. Upload to your server:
const form = new FormData();
form.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: form,
});
const { url } = await response.json();
return url; // Return the server URL.
},
resolve(asset) {
if (asset.type === "image") {
return asset.props.src;
}
return null;
},
};
<Tldraw assets={assetStore} />Now pasted/dropped images upload to your server. The asset record stores only the URL — snapshots stay small.
For S3/Cloudflare R2 directly from the browser (presigned URLs):
async upload(asset, file) {
// 1. Get a presigned URL from your backend:
const { uploadUrl, finalUrl } = await fetch("/api/presign", {
method: "POST",
body: JSON.stringify({ filename: asset.props.name, type: file.type }),
}).then((r) => r.json());
// 2. Upload directly to S3/R2:
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "content-type": file.type },
});
// 3. Return the final URL:
return finalUrl;
}This bypasses your backend for the actual file bytes — fast and scalable.
Common Mistake: Returning a URL that’s not CORS-accessible by the canvas. The browser canvas tainting rules can break export if your asset URLs don’t allow crossOrigin.
Fix 5: Multi-User Sync via tldraw Sync
For real-time collaboration, use @tldraw/sync (self-hosted server) or tldraw’s hosted sync:
npm install @tldraw/syncServer (Node/Cloudflare Worker):
import { TLSocketRoom } from "@tldraw/sync-core";
const room = new TLSocketRoom({
initialSnapshot: undefined, // Or load from DB
onSessionRemoved: (room) => {
if (room.getNumActiveSessions() === 0) {
// Persist the snapshot
}
},
onDataChange: () => {
// Throttled snapshot persistence
},
});
// In your WebSocket handler:
room.handleSocketConnect({ sessionId, socket });Client:
import { useSync } from "@tldraw/sync";
import { Tldraw } from "@tldraw/tldraw";
function CollabBoard({ roomId }: { roomId: string }) {
const store = useSync({
uri: `wss://my-server.com/connect/${roomId}`,
assets: assetStore,
});
return <Tldraw store={store} />;
}useSync opens a WebSocket and returns a synced store. Changes flow bi-directionally.
For Cloudflare Workers Durable Objects, tldraw provides a template — each room is a DO that holds connected sockets and the canonical state. This pairs beautifully with edge-distributed apps.
Pro Tip: Self-host the sync server unless you specifically want managed. The protocol is well-documented and the code is small (~few hundred lines).
Fix 6: Working With the Editor
For programmatic control, use the editor instance via onMount:
import { Tldraw, Editor } from "@tldraw/tldraw";
function MyBoard() {
const [editor, setEditor] = useState<Editor | null>(null);
return (
<Tldraw
onMount={(editor) => {
setEditor(editor);
}}
/>
);
}Or with useEditor() inside a child component:
import { useEditor } from "@tldraw/tldraw";
function Toolbar() {
const editor = useEditor();
return (
<button onClick={() => editor.selectAll()}>Select All</button>
);
}
<Tldraw>
<Toolbar />
</Tldraw>Common editor methods:
editor.selectAll();
editor.deleteShapes(editor.getSelectedShapeIds());
editor.createShape({ type: "geo", x: 100, y: 100, props: { geo: "rectangle" } });
editor.zoomToFit();
editor.zoomToContent();
editor.setCurrentTool("draw");
const allShapes = editor.getCurrentPageShapes();
const selected = editor.getSelectedShapeIds();
// Listen to changes:
editor.store.listen(() => {
console.log("store updated");
});For undo/redo:
editor.undo();
editor.redo();
// Mark a transaction:
editor.batch(() => {
editor.createShape(...);
editor.createShape(...);
}); // All shapes added in one undo step.Fix 7: Snapshots — Load and Save
Snapshots are the serialized form of the store:
import { getSnapshot, loadSnapshot } from "@tldraw/tldraw";
// Save:
const snapshot = getSnapshot(editor.store);
const json = JSON.stringify(snapshot);
// Persist to backend.
// Load:
const parsed = JSON.parse(json);
loadSnapshot(editor.store, parsed);getSnapshot returns a structured object — schema version + records. Treat as opaque JSON.
For initial load on mount:
<Tldraw
snapshot={initialSnapshot} // Provided once at mount
/>Or load after mount:
<Tldraw
onMount={(editor) => {
if (initialSnapshot) {
editor.store.loadSnapshot(initialSnapshot);
}
}}
/>For incremental updates from a remote source, apply changes through the sync protocol — not by loading new snapshots.
Common Mistake: Saving snapshots on every change. Throttle to 1-5 seconds; snapshots can be large (especially with embedded assets).
Fix 8: Dark Mode
tldraw has built-in dark mode:
editor.user.updateUserPreferences({ colorScheme: "dark" });
// Or "light", "system"Or via component:
<Tldraw inferDarkMode />inferDarkMode makes tldraw match the system’s color scheme via media query.
For custom theming, override CSS variables in your stylesheet:
.tl-container {
--color-background: #1a1a1a;
--color-text: #ffffff;
--color-grid: #333;
}Inspect the tldraw CSS bundle for the full list of variables.
Pro Tip: tldraw’s UI is React under the hood. You can swap UI components via components prop:
<Tldraw
components={{
Toolbar: MyCustomToolbar,
StylePanel: MyStylePanel,
}}
/>Customize the full UI without forking the library.
Still Not Working?
A few less-obvious failures:
- Canvas not interactive. Pointer events blocked by a CSS overlay. Inspect with DevTools — ensure no parent has
pointer-events: none. - Persistence loses data after schema migration. tldraw versions sometimes change shape schemas. Old data may not load in new versions. Implement migrations via
migrationsconfig. - Slow with many shapes. Hundreds is fine; thousands may need a stress test. Consider virtualization or pagination for huge canvases.
- Asset URLs 404 after deploy. Snapshot stores absolute URLs. Move buckets and old snapshots break. Use relative paths or a CDN with stable URLs.
- Server sync drops connections. WebSocket timeouts. Configure your reverse proxy (nginx, Cloudflare) to allow long-lived WS — 60s+.
- Cursor positions don’t sync. Awareness/presence is separate from store sync. tldraw sync handles both; verify both channels are connected.
- Export to PNG/SVG renders blank. Images with
crossOrigin: anonymousheaders required. Configure your asset server’s CORS. useSyncreturns undefined. Connection still establishing. Show a loading state until the store is ready.
For related React, canvas, and real-time collaboration issues, see React hydration error, Liveblocks not working, Supabase Realtime not working, and Socket.IO not connecting.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Inertia.js Not Working — Shared Data, Lazy Props, Versioning, Forms, and SSR
How to fix Inertia.js errors — Inertia.render not returning a component, shared data missing on every page, lazy props not deferring, asset versioning forcing reloads, useForm helper, and SSR setup.
Fix: next-themes Not Working — Hydration Mismatch, Tailwind Dark Mode, FOUC, and System Preference
How to fix next-themes errors — hydration mismatch on mount, FOUC flash before theme applies, Tailwind dark: classes not switching, ThemeProvider in App Router, defaultTheme system not respected, and TypeScript types.
Fix: PGlite Not Working — IndexedDB Persistence, Worker Setup, Extensions, and Live Queries
How to fix PGlite errors — async init not awaited, IndexedDB persistence lost on reload, Web Worker isolation, pgvector and other extensions, live queries with @electric-sql/pglite-react, and migration patterns.
Fix: Playwright Component Testing Not Working — Mount Fixture, Vite Config, Styles, and TypeScript
How to fix Playwright component testing errors — playwright-ct.config not found, mount fixture undefined, CSS not loaded in tests, Vite alias for imports, TypeScript paths, hooks (beforeMount), and snapshot strategy.