Storage Overview
Purpose
This document describes how LinkHub persists multi-workspace state, gallery images, templates, custom themes, and local usage statistics in the browser today.
The implementation deliberately separates structured records from binary payloads:
- Workspace state is stored as JSON documents.
- Image assets are stored as metadata plus blob payloads.
- Templates and themes are stored independently from workspace records.
- Workspace, template, and card records reference images by id instead of embedding image bytes.
That keeps workspace documents compact, allows fast local snapshots, and avoids duplicating the same image across multiple references.
Storage Backends
LinkHub currently uses two browser-local persistence layers.
IndexedDB
IndexedDB is the primary persistent store.
- Database name:
linkhub - Version:
5 - Stores:
workspaceworkspace_metadataimage_assetsimage_blobstemplatestemplate_image_assetstemplate_image_blobsthemes
Implementation:
localStorage
localStorage is the snapshot and fallback layer for workspace state and workspace-directory metadata. It is not used for binary assets.
Current keys:
linkhub.workspacefor the last active workspace snapshotlinkhub.workspace-directoryfor workspace-directory metadatalinkhub.workspace.<workspaceId>for per-workspace JSON snapshots
Images, template blobs, and theme assets are never written to localStorage.
Implementation:
Workspace Model
Workspace records
Each workspace record is validated through the Zod workspace schema.
Relevant fields include:
idnameappearanceanalyticsplacementGuideviewportgroupscardspictures- every free-floating node: pictures, charts and news feedscreatedAtupdatedAt
Implementation:
Workspace directory
LinkHub no longer persists a single default board only. It keeps a workspace directory that tracks:
activeWorkspaceIdinteractionModeworkspaceRailPinned- workspace summaries used by the rail UI
Each full workspace record is stored separately, while the directory keeps the list and UI session metadata.
Implementation:
Load And Save Lifecycle
Load flow
On application startup:
Apploads a workspace session, not just a single workspace record.- The repository loads the workspace directory and resolves the active workspace.
- IndexedDB is used for the workspace directory and stored workspace records when available, with localStorage directory metadata as the fallback path.
- For the active workspace only, the latest
linkhub.workspacelocalStorage snapshot is preferred when it matches the active workspace id because it may be newer than the last IndexedDB write. - Loaded workspace documents are normalized through migrations before hydration.
- After hydration, LinkHub records a local canvas-open event for the Statistics view.
Load orchestration:
Save flow
After the workspace is hydrated and the store is ready:
- A localStorage snapshot is scheduled after
100 ms. - An IndexedDB save is scheduled after
300 ms. - The current workspace is flushed on
pagehideand when the document becomes hidden. - Workspace-directory changes such as interaction mode and workspace rail pinning are persisted separately.
This gives fast crash resilience without making every UI update wait on IndexedDB.
Save orchestration:
Local analytics
Workspace records contain a local analytics section that powers the Statistics tab.
Current examples:
- canvas opens
- link opens per card
- time-bucketed history used for charts
These metrics stay local to the current browser profile. They are reset from exported workspace JSON so canvas bundles do not transport local usage history by default.
Implementation:
- ../src/contracts/workspaceAnalytics.ts
- ../src/features/analytics/workspaceAnalytics.ts
- ../src/features/importExport/canvasBundle.ts
Free-Floating Nodes
workspace.pictures holds every node that is not a link card or a group. The
type field tells them apart:
type |
Contract | Stores |
|---|---|---|
picture |
src/contracts/pictureNode.ts |
imageId, position, size |
chart |
src/contracts/chartNode.ts |
feed URL, symbol, chart settings (see CHART_FEED.md) |
feed |
src/contracts/feedNode.ts |
feed sources, proxy URL, display settings (see NEWS_FEED.md) |
These nodes have no groupId. A node belongs to a group when it lies inside
the group body. When a group is collapsed, it records the ids of its member
nodes in groups[].collapsedPictureIds, because collapsing moves the nodes
below the group up and bounds alone could then pick up a node that is not a
member. The list is cleared when the group expands.
Chart data and feed articles are not stored. They are fetched again when the node renders. A feed node caches its result in memory only.
Images And Picture Nodes
Workspace references
Picture nodes live inside the workspace JSON because they are part of the canvas layout, but they only store references to image assets.
Current reference points:
pictures[].imageIdfor standalone picture nodescards[].faviconOverrideImageIdfor custom card images
The actual image bytes remain app-wide records outside the workspace document.
Implementation:
Image stores
Image storage is split into two stores:
image_assetsstores metadata such as name, file size, dimensions, mime type, and timestamps.image_blobsstores the matching binary payload keyed by the same asset id.
The same stored image can be reused by multiple cards, pictures, templates, or imported bundles.
Implementation:
Supported formats
Current supported formats:
- APNG
- AVIF
- GIF
- JPEG
- PNG
- SVG
- WebP
Format resolution:
Deletion flow
Image deletion is usage-aware.
Before deletion, LinkHub checks whether an image is still referenced by:
- picture nodes
- link-card image overrides
If the user confirms deletion:
- dependent picture nodes are removed
- dependent card overrides are cleared
- metadata and blob entries are deleted from IndexedDB
Usage logic:
Templates
Templates are stored separately from workspaces.
templatesstores the serializedTemplateDocumenttemplate_image_assetsstores image metadata keyed by<templateId>:<imageId>template_image_blobsstores the corresponding binary payloads
Template records can include preview thumbnails and copied image references so a template remains portable even if the original workspace changes later.
There are two template-related formats in the app:
- The on-disk template document format is
linkhub.template - Downloaded template exports use
.template.jsonand embed image data URLs for transport
Implementation:
- ../src/contracts/template.ts
- ../src/storage/templateRepository.ts
- ../src/features/templates/templateLibrary.ts
- ../src/components/taskbar/OptionsMenu.tsx
Themes
Themes are also stored independently from workspaces.
- Built-in themes are shipped in code
- Saved or imported custom themes are persisted in the
themesstore
Current built-in theme set:
- Excalidraw
- Blueprint
- Minimal
- Nord
- Sunset
- Neon
Custom theme exports use .linkhub-theme.json.
Implementation:
- ../src/contracts/theme.ts
- ../src/features/themes/builtinThemes.ts
- ../src/features/themes/themeImportExport.ts
- ../src/storage/themeRepository.ts
Canvas Bundle Import And Export
LinkHub can export and import the current canvas as a ZIP bundle.
Bundle structure
The current bundle layout contains:
manifest.jsonworkspace.jsontemplates.jsonthemes.jsonimages/*template-images/<templateId>/*
Current bundle file extension:
.linkhub.zip
Export behavior
Export is started from the Data tab and currently includes:
- the current workspace structure
- cards, groups, picture nodes, viewport, and appearance settings
- the whole local image gallery
- all saved templates plus copied template image files
- all saved custom themes
Workspace analytics are stripped from the exported workspace JSON before serialization.
Implementation:
- ../src/components/taskbar/OptionsDataSection.tsx
- ../src/components/taskbar/OptionsMenu.tsx
- ../src/features/importExport/canvasBundle.ts
Import behavior
Import supports two flows:
- replace the current canvas
- import the bundle as a brand-new workspace
During import:
- bundled images are reconciled against existing stored images
- image ids may be remapped to avoid collisions
- bundled templates are restored into the local template library
- bundled custom themes are restored into the local theme library
- unrelated gallery images, templates, and themes already on the device are kept
If a bundle is missing an image file that is referenced by the workspace payload, import fails instead of restoring incomplete data.
Implementation:
Storage Statistics
The Statistics tab computes logical storage buckets for:
- groups
- cards
- pictures plus referenced workspace image assets
- gallery-only images
- templates
- themes
It also reports:
- the approximate current-board payload size
- the size of the localStorage snapshot
- the practical localStorage budget (
5 MiB) - browser-reported origin usage and quota when
navigator.storage.estimate()is available
Implementation:
Migration And Recovery
Workspace reads always pass through a migration layer.
Current migration responsibilities include:
- recovering malformed or partial workspace documents
- normalizing appearance fields from older shapes
- coercing card data into current schema
- coercing picture nodes into the current pictures array
- preserving safe defaults when fields are missing
Implementation: ../src/storage/storageMigrations.ts
If a workspace cannot be loaded cleanly, LinkHub falls back to a fresh default workspace rather than crashing.
Store-Level Responsibilities
Zustand Store
The workspace store owns in-memory editing state and mutations for:
- cards
- groups
- pictures
- selection
- undo history
- viewport and appearance
The store updates workspace timestamps through the workspace replacement helpers.
Implementation: ../src/state/useWorkspaceStore.ts
Repositories
Repository responsibilities are intentionally narrow.
- workspaceRepository handles workspace-session bootstrap, workspace record loading and saving, workspace-directory persistence, localStorage fallback snapshots, and workspace deletion
- imageRepository handles image metadata and blob persistence
- templateRepository handles template documents plus copied template image records
- themeRepository handles persisted custom theme documents
This separation keeps canvas editing logic out of persistence primitives.
Repository Pattern
The repositories share a consistent module style, but workspaceRepository is intentionally broader because it also owns session bootstrap and workspace-directory persistence.
Common Operations
| Operation | workspace | image | template | theme |
|---|---|---|---|---|
| list | loadWorkspaceDirectory() | listImageAssets() | listTemplates() | listThemes() |
| get | loadWorkspace(id) | getImageAsset(id), getStoredImageAssetRecord(id) | getTemplate(id) | getTheme(id) |
| bootstrap | loadWorkspaceSession() | - | - | - |
| put | saveWorkspace(ws), saveWorkspaceDirectory() | saveImageAsset({ file, ... }), putStoredImageAssetRecord(record) | putTemplate({ template, records }) | putTheme(theme) |
| delete | deleteWorkspaceRecord(id) | deleteImageAsset(id) | deleteTemplate(id) | deleteTheme(id) |
Conventions
- Each repository is a module of plain async functions, not a class.
- All data access goes through
openLinkHubDb()fromdb.ts. - Workspace, template, and theme writes serialize plain JSON records before writing to IndexedDB.
imageRepositoryinstead normalizes image metadata and stores blobs in a separate object store. - Workspace reads normalize persisted data through
ensureLatestWorkspace(). Template and theme list operations validate stored records with Zod and drop invalid entries.imageRepositoryreturns the stored image metadata directly. - Repositories that own multiple object stores use a single IndexedDB transaction when deleting related records.
workspaceRepositoryalso maintains localStorage fallback snapshots and persists workspace-directory metadata separately from individual workspace records.
Why No Shared TypeScript Interface
The put signatures are structurally different across repositories (e.g. image requires a File, template bundles images alongside the document). A forced generic interface would obscure these differences without adding safety. The documented pattern above serves as the contract instead.
Current Limitations
The current implementation has a few intentional boundaries.
- Only the default workspace is persisted today.
- Images are app-wide in IndexedDB, but multi-workspace management is not exposed yet.
- localStorage fallback covers workspace JSON only, not image blobs.
- Merge-style import is not implemented; importing replaces the current canvas.
- Template export/import and multi-workspace bundle flows are not implemented yet.
Practical Summary
If you need to reason about storage in LinkHub, use this model:
- Workspace equals canvas structure and settings.
- Image assets equals reusable binary resources plus metadata.
- Workspace references images by id.
- IndexedDB is the source of truth.
- localStorage is only a safety snapshot for the workspace document.