apps/docs/content/sdk-features/assets.mdx
Assets are external resources like images, videos, and bookmarks that shapes display on the canvas. They're stored as separate records in the store and referenced by ID from shapes. This lets you reuse the same image across multiple shapes without duplicating data, and swap out storage backends without touching your shapes.
The SDK includes three asset types: image, video, and bookmark. Each asset record holds metadata (dimensions, MIME type, source URL) while the actual file lives wherever you want to put it. You provide upload and resolve handlers that tell tldraw how to store files and fetch them for rendering.
Assets live in the store alongside shapes and pages. Each asset record contains metadata (dimensions, MIME type, name) but not the actual file bytes—those live in your storage backend.
When someone drops an image onto the canvas, tldraw creates two records: an asset record with dimensions and metadata, and a shape record with position and size. The shape references the asset through its assetId property. Multiple shapes can reference the same asset, so deleting one image shape won't remove the asset if other shapes still use it.
Asset records have a props object for type-specific properties and a meta object for your custom data. The src property in props holds the URL returned by your upload handler—this can be an HTTP URL, a data URL, or any string your resolve handler understands.
The SDK defines three built-in asset types.
Image assets store raster images like PNG, JPEG, or GIF. They track width, height, MIME type, animation status, and file size. The isAnimated flag is true for animated GIFs.
const imageAsset: TLImageAsset = {
id: 'asset:image123' as TLAssetId,
typeName: 'asset',
type: 'image',
props: {
w: 1920,
h: 1080,
name: 'photo.jpg',
isAnimated: false,
mimeType: 'image/jpeg', // can be null if unknown
src: 'https://storage.example.com/uploads/photo.jpg', // can be null before upload
fileSize: 245000, // optional
},
meta: {},
}
Video assets store video files like MP4 or WebM. They have the same structure as image assets: dimensions, MIME type, source URL, and isAnimated (which is typically true for videos).
const videoAsset: TLVideoAsset = {
id: 'asset:video456' as TLAssetId,
typeName: 'asset',
type: 'video',
props: {
w: 1920,
h: 1080,
name: 'clip.mp4',
isAnimated: true,
mimeType: 'video/mp4',
src: 'https://storage.example.com/uploads/clip.mp4',
fileSize: 5242880,
},
meta: {},
}
Bookmark assets store web page previews. When someone pastes a URL, tldraw fetches metadata from the page and creates a bookmark that renders as a preview card.
const bookmarkAsset: TLBookmarkAsset = {
id: 'asset:bookmark1' as TLAssetId,
typeName: 'asset',
type: 'bookmark',
props: {
title: 'Example Website',
description: 'A great example of web design',
image: 'https://example.com/preview.jpg',
favicon: 'https://example.com/favicon.ico',
src: 'https://example.com',
},
meta: {},
}
TLAssetStore defines how tldraw talks to your storage backend. You provide an implementation when creating the editor, and tldraw calls your handlers whenever someone adds or accesses assets.
The default behavior depends on your store setup:
inlineBase64AssetStore converts images to data URLs—quick for prototyping but doesn't persist across sessionspersistenceKey: Assets are stored in the browser's IndexedDB alongside other document dataTLAssetStore to upload files to a storage service like S3, Google Cloud Storage, or your own APIThe interface has three methods:
| Method | Purpose |
|---|---|
upload | Store a file and return its URL |
resolve | Return the URL to use when rendering an asset |
remove | Clean up files when assets are deleted (optional) |
The upload method receives an asset record (with metadata already populated) and the File to store. Return an object with src (the URL) and optionally meta (custom metadata to merge into the asset record). You also get an AbortSignal for cancellation.
async upload(asset: TLAsset, file: File, abortSignal?: AbortSignal): Promise<{ src: string; meta?: JsonObject }>
The resolve method receives an asset and a TLAssetContext describing how the asset is being displayed. Return the URL to use for rendering, or null if unavailable. This is where you can get clever—return optimized thumbnails when zoomed out, high-resolution images for export, or add authentication tokens.
resolve(asset: TLAsset, ctx: TLAssetContext): Promise<string | null> | string | null
The remove method receives asset IDs that are no longer needed—clean up the stored files to free space. This method is optional.
async remove(assetIds: TLAssetId[]): Promise<void>
Here's a minimal implementation that converts files to data URLs (good for prototyping, not so great for production):
import { Tldraw, TLAssetStore } from 'tldraw'
import 'tldraw/tldraw.css'
const assetStore: TLAssetStore = {
async upload(asset, file) {
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
})
return { src: dataUrl }
},
resolve(asset, ctx) {
return asset.props.src
},
}
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw assets={assetStore} />
</div>
)
}
When resolving assets, tldraw gives you a TLAssetContext with information about the current render environment. Use this to optimize asset delivery.
| Property | Type | Description |
|---|---|---|
screenScale | number | How much the asset is scaled relative to native dimensions. A 1000px image rendered at 500px has screenScale 0.5. |
steppedScreenScale | number | screenScale rounded to the nearest power of 2, useful for tiered caching. |
dpr | number | Device pixel ratio. Retina displays are 2 or 3. |
networkEffectiveType | string | null | Browser's connection type: 'slow-2g', '2g', '3g', or '4g'. |
shouldResolveToOriginal | boolean | True when exporting or copy-pasting. Return full quality. |
Here's a resolve handler that serves optimized images based on context—notice how you can tailor the response to network conditions and zoom level:
resolve(asset, ctx) {
const baseUrl = asset.props.src
if (!baseUrl) return null
// For exports, always return original
if (ctx.shouldResolveToOriginal) {
return baseUrl
}
// On slow connections, serve lower quality
if (ctx.networkEffectiveType === 'slow-2g' || ctx.networkEffectiveType === '2g') {
return `${baseUrl}?quality=low`
}
// Serve resolution appropriate for current zoom
const targetWidth = Math.ceil(asset.props.w * ctx.steppedScreenScale * ctx.dpr)
return `${baseUrl}?w=${targetWidth}`
}
The Editor class provides methods for managing assets:
| Method | Description |
|---|---|
| Editor#createAssets | Add asset records to the store |
| Editor#updateAssets | Update existing assets with partial data |
| Editor#deleteAssets | Remove assets and call the remove handler |
| Editor#getAsset | Get an asset by ID |
| Editor#getAssets | Get all assets in the store |
| Editor#resolveAssetUrl | Resolve an asset ID to a renderable URL |
Asset operations happen outside the undo/redo history since they're typically part of larger operations like pasting images—you don't want "undo" to magically un-upload a file.
// Create an asset
editor.createAssets([imageAsset])
// Update an asset (only provide changed fields)
editor.updateAssets([{ id: imageAsset.id, type: 'image', props: { name: 'new-name.jpg' } }])
// Get an asset with type safety
const asset = editor.getAsset<TLImageAsset>(imageAsset.id)
// Resolve to a URL for rendering
const url = await editor.resolveAssetUrl(imageAsset.id, { screenScale: 0.5 })
// Delete assets
editor.deleteAssets([imageAsset.id])
Shapes reference assets through an assetId property in their props. Image shapes, video shapes, and bookmark shapes all follow this pattern. The shape stores position, size, rotation, and crop settings while the asset stores the media metadata and source URL.
This separation pays off:
src and every shape referencing it updates immediatelyWhen you delete an asset, shapes referencing it fall back to displaying the URL directly or show a placeholder.
Implement TLAssetStore to integrate with any storage backend. For local development, convert files to data URLs. For production, upload to S3, Google Cloud Storage, or your own API—whatever works for you.
Here's an example that uploads to a custom API:
const assetStore: TLAssetStore = {
async upload(asset, file, abortSignal) {
const formData = new FormData()
formData.append('file', file)
formData.append('assetId', asset.id)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
signal: abortSignal,
})
const { url, uploadedAt } = await response.json()
return {
src: url,
meta: { uploadedAt }, // Custom metadata gets merged into the asset
}
},
resolve(asset, ctx) {
// Add auth token for private content
const token = getAuthToken()
return `${asset.props.src}?token=${token}`
},
async remove(assetIds) {
await fetch('/api/assets', {
method: 'DELETE',
body: JSON.stringify({ ids: assetIds }),
})
},
}
Custom asset types let you store domain-specific media alongside images, videos, and bookmarks. Each asset type has a corresponding AssetUtil that defines type-specific behavior: which MIME types it accepts, how to derive an asset record from a dropped file, and what default props new instances start with. The built-in ImageAssetUtil, VideoAssetUtil, and BookmarkAssetUtil follow this pattern and live in defaultAssetUtils.
AssetUtil is the asset-side counterpart to ShapeUtil. You register one util per type on the editor at startup, and the editor calls its methods whenever a file enters the system.
Register your asset's props on TLGlobalAssetPropsMap via TypeScript module augmentation, then implement an AssetUtil for it:
import { AssetUtil, TLAsset, TLAssetId } from 'tldraw'
const AUDIO_TYPE = 'audio'
declare module 'tldraw' {
export interface TLGlobalAssetPropsMap {
[AUDIO_TYPE]: {
src: string | null
mimeType: string | null
name: string
}
}
}
type TLAudioAsset = TLAsset<typeof AUDIO_TYPE>
class AudioAssetUtil extends AssetUtil<TLAudioAsset> {
static override type = AUDIO_TYPE
override getDefaultProps(): TLAudioAsset['props'] {
return { src: null, mimeType: null, name: '' }
}
override getSupportedMimeTypes() {
return ['audio/mpeg', 'audio/wav', 'audio/ogg']
}
override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLAudioAsset | null> {
return {
id: assetId,
typeName: 'asset',
type: AUDIO_TYPE,
props: {
src: null, // populated by the asset store after upload
mimeType: file.type,
name: file.name,
},
meta: {},
}
}
}
Pass the util to the <Tldraw> component alongside whichever defaults you still want:
import { Tldraw, defaultAssetUtils } from 'tldraw'
const assetUtils = [...defaultAssetUtils, AudioAssetUtil]
export default function App() {
return <Tldraw assetUtils={assetUtils} />
}
When a file is dropped or pasted, the editor finds the first registered util whose getSupportedMimeTypes() includes the file's MIME type and calls its getAssetFromFile(). The returned asset record then flows through your TLAssetStore.upload handler, which assigns the final src. Custom shape utils that render audio (or whatever else you registered) read the resolved URL through editor.resolveAssetUrl() like the built-in shapes do.
Use AssetUtil#configure to tweak options on a built-in util without subclassing it. For example, lock image uploads down to PNG:
import { ImageAssetUtil, defaultAssetUtils, Tldraw } from 'tldraw'
const PngOnlyImageAssetUtil = ImageAssetUtil.configure({
supportedMimeTypes: ['image/png'],
})
const assetUtils = defaultAssetUtils.map((util) =>
util === ImageAssetUtil ? PngOnlyImageAssetUtil : util
)
<Tldraw assetUtils={assetUtils} />
ImageAssetUtil and VideoAssetUtil both expose maxDimension and supportedMimeTypes options.
Asset records use the migration system to evolve their schema. Each asset type has its own migration sequence that handles adding properties, renaming fields, and validating data. When you load a document with old asset records, migrations transform them to the current schema automatically.
Validators ensure asset data matches the expected structure at runtime. Use createAssetValidator to build a validator for your custom asset type—it produces a discriminated union on the type field. Add migration sequences to handle schema changes over time.
SVG files can contain scripts, event handlers, and external resource references that execute during rendering. tldraw automatically sanitizes all SVGs on paste and file drop using an allowlist-based sanitizer that:
<script>, <iframe>, <object>, <embed>, and other dangerous elementson* event handler attributes (onerror, onload, etc.)javascript: and data: URIs in links<image> and <feImage> hrefs to data: URIs only<use> hrefs to fragment references (#id) only@import, expression(), and external url() references<foreignObject> content (needed for text rendering) with a separate HTML allowlist<style> elements with data: font URLs (needed for embedded fonts)For stronger guarantees, we recommend using DOMPurify — a battle-tested, widely-audited sanitizer. We don't bundle it to avoid imposing a ~17KB dependency, but if your app already uses it or you want the extra assurance, you can wire it into your external content handlers. Note that DOMPurify's default SVG profile strips <foreignObject> and <style> elements, which tldraw uses for text rendering and embedded fonts — you'll need to configure it to preserve those for tldraw's SVG output to round-trip correctly.
If you're implementing custom external content handlers, you can also import our built-in sanitizer directly:
import { sanitizeSvg } from 'tldraw'
const sanitized = sanitizeSvg(svgText)
if (!sanitized) {
// SVG contained no safe content
}
For defense in depth, we recommend deploying a Content Security Policy. This protects against attack vectors that sanitization alone cannot cover:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https://your-asset-domain.com;
font-src 'self' data:;
connect-src 'self' https://your-api.com;
object-src 'none';
base-uri 'self';
script-src 'self' prevents inline scripts in SVGs from executingstyle-src 'self' 'unsafe-inline' allows tldraw's inline styles while blocking external stylesheet loading ('unsafe-inline' is needed for tldraw's runtime styles)img-src 'self' data: blob: allows data URLs for embedded images and blob URLs for asset previews, while blocking loads to arbitrary external originsobject-src 'none' blocks <object> and <embed> elements entirelybase-uri 'self' prevents <base> tag injection that could redirect relative URLsWe recommend serving user-uploaded assets from a completely separate domain (e.g. example-assets.com rather than a subdomain like assets.example.com). This provides an extra layer of protection: if a malicious file somehow bypasses sanitization, browser same-origin policies prevent it from accessing cookies, storage, or APIs on your main domain.