--- title: Assets created_at: 12/20/2025 updated_at: 12/29/2025 keywords: - assets - images - videos - bookmarks - files - upload - media - storage status: published date: 12/20/2025 readability: 9 voice: 9 completeness: 9 accuracy: 10 notes: '' --- 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. ## How it works ### Asset records and the store 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. Deleting a shape never deletes its asset: call [Editor#deleteAssets](?) yourself when you know an asset is no longer referenced. 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. ### Asset types 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, file size, and an optional `pixelRatio` for @2x images. The `isAnimated` flag is true for animated GIF, WebP, AVIF, and APNG files. ```typescript 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 pixelRatio: 2, // 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). ```typescript 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. ```typescript 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: {}, } ``` ### The TLAssetStore interface [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. With an in-memory store (the default), [inlineBase64AssetStore](?) converts every uploaded file to a data URL: quick for prototyping, but nothing persists across sessions. With a [`persistenceKey`](/sdk-features/persistence#The-persistenceKey-prop), assets are stored in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) alongside the document. With a [sync server](/docs/sync), implement `TLAssetStore` yourself to upload files to S3, Google Cloud Storage, or your own API. The interface has three methods: | Method | Purpose | | --------- | --------------------------------------------------------------------------------- | | `upload` | Store a file and return its URL | | `resolve` | Return the URL to use when rendering an asset (optional, defaults to `props.src`) | | `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. ```typescript 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. It can be sync or async. Return the URL to use for rendering, or `null` if the asset is unavailable (shapes then render a broken-asset placeholder). This is where you can get clever: return optimized thumbnails when zoomed out, high-resolution images for export, or add authentication tokens. ```typescript resolve(asset: TLAsset, ctx: TLAssetContext): Promise | 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. ```typescript async remove(assetIds: TLAssetId[]): Promise ``` Here's a minimal implementation that converts files to data URLs (good for prototyping, not so great for production): ```typescript import { Tldraw, TLAssetStore } from 'tldraw' import 'tldraw/tldraw.css' const assetStore: TLAssetStore = { async upload(asset, file) { const dataUrl = await new Promise((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 (
) } ``` ### The TLAssetContext 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 up to the next 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 for copy/paste and for SVG exports without an explicit `pixelRatio`. Return full quality. | Here's a resolve handler that serves optimized images based on network conditions and zoom level: ```typescript 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}` } ``` ## Key components ### Editor asset methods The [Editor](?) class provides methods for managing assets: | Method | Description | | --------------------------- | ----------------------------------------- | | [Editor#createAssets](?) | Add asset records to the store | | [Editor#updateAssets](?) | Update existing assets | | [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. ```typescript // Create an asset editor.createAssets([imageAsset]) // Update an asset. updateAssets shallow-merges the record, so spread the existing props editor.updateAssets([ { ...imageAsset, props: { ...imageAsset.props, name: 'new-name.jpg' }, }, ]) // Get an asset with type safety const asset = editor.getAsset(imageAsset.id) // Resolve to a URL for rendering const url = await editor.resolveAssetUrl(imageAsset.id, { screenScale: 0.5 }) // Delete assets editor.deleteAssets([imageAsset.id]) ``` ### Shape and asset relationships 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: - Update an asset's `src` and every shape referencing it updates immediately - Duplicate a shape without duplicating storage - Implement lazy loading where assets only load when shapes become visible When you delete an asset, shapes referencing it render a broken-asset placeholder. ## Extension points ### Custom storage backends 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. Here's an example that uploads to a custom API: ```typescript 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 [AssetUtil](?) is the asset-side counterpart to `ShapeUtil`. Each asset type has one, and it defines which MIME types the type 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` live in `defaultAssetUtils`. To add your own type, register its props on `TLGlobalAssetPropsMap` via TypeScript module augmentation, then implement an `AssetUtil` for it. Set `static props` so the store validates and migrates your records: ```typescript import { AssetUtil, T, 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 class AudioAssetUtil extends AssetUtil { static override type = AUDIO_TYPE static override props = { src: T.string.nullable(), mimeType: T.string.nullable(), name: T.string, } 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 { 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 `` component. The default asset utils are always included; a custom util with the same `type` replaces the built-in one: ```tsx import { Tldraw } from 'tldraw' const assetUtils = [AudioAssetUtil] export default function App() { return } ``` When a file is dropped or pasted, the editor checks it against `maxAssetSize`, then finds the first registered util whose `acceptsMimeType()` returns true for 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`. To place the asset on the canvas, a shape util must declare the asset type in `static handledAssetTypes` and implement `createShapeForAsset()`; that shape reads the resolved URL through `editor.resolveAssetUrl()` like the built-in shapes do. See the [custom asset type example](/examples/data/assets/custom-asset-type) for the full pattern. ### Configuring built-in asset utils The simplest way to configure the built-in utils is through the `` props `maxAssetSize`, `maxImageDimension`, `acceptedImageMimeTypes`, and `acceptedVideoMimeTypes`. For anything else, use [AssetUtil#configure](?) to tweak options on a built-in util without subclassing it. For example, lock image uploads down to PNG: ```tsx import { ImageAssetUtil, defaultAssetUtils, Tldraw } from 'tldraw' const PngOnlyImageAssetUtil = ImageAssetUtil.configure({ supportedMimeTypes: ['image/png'], }) const assetUtils = defaultAssetUtils.map((util) => util === ImageAssetUtil ? PngOnlyImageAssetUtil : util ) ``` `ImageAssetUtil` exposes `maxDimension` and `supportedMimeTypes` options. `VideoAssetUtil` exposes `supportedMimeTypes`. If you also pass the matching `` props, those win. ### Asset validation and migrations 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. Setting `static props` on your `AssetUtil` is enough for the store to validate it. If you need a standalone validator, [createAssetValidator](?) builds one for a single asset type (id, `typeName`, `type` literal, props, and meta). Add migration sequences via `static migrations` to handle schema changes over time. ## Security ### SVG sanitization 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: - Strips `