SceneVault developer docs
SceneVault is a personal Excalidraw library service — nested folders, unlimited scenes, cloud autosave, shareable links, and real-time collaboration. This guide covers the architecture in depth: the data flows, the trust boundaries, the consistency model, and how every failure mode is handled.
Introduction
Overview
Your Excalidraw drawings keep ending up in a folder called Downloads. SceneVault gives them a real home: a tidy, searchable library where every scene autosaves to the cloud and the full editor is one click away.
It is a single Next.js App Router application that stitches together four services: Better Auth for authentication, Convex as a reactive metadata database, Cloudflare R2 for scene-bundle storage, and the embedded @excalidraw/excalidraw editor. The guiding principle is a clean split: scene metadata (titles, folders, versions, object keys, content hashes) lives in Convex and is strongly consistent and reactive; the heavy scene bundles live in R2 and are accessed only through short-lived presigned URLs that never touch the Next.js server.
Get running
Quickstart
The fastest way to try SceneVault is local demo mode, which needs no Better Auth, Convex, or R2 credentials. Scenes are stored in your browser's localStorage.
# 1. Install dependencies
pnpm install
# 2. Run in local demo mode (no external services)
NEXT_PUBLIC_LOCAL_DATA=1 pnpm dev
# 3. Open the dashboard
open http://localhost:3000/dashboardNo sign-in required
Ready to wire up real services and persist scenes across devices? Jump to Production setup.
Capabilities
Features
Everything you need to keep a growing pile of drawings calm, searchable, and saved.
Nested folders
Organise scenes into folders nested as deep as you like. Move and rename freely — cycle-safe moves are enforced server-side.
Cloud autosave
Every edit streams to Cloudflare R2 and a content hash skips no-op writes, so the version only bumps when the drawing truly changes.
The real editor
The full @excalidraw/excalidraw canvas is embedded — not a clone. All native tools, libraries, and exports work as expected.
Instant search
Filter the whole library by scene title or folder name in a keystroke, entirely client-side over a live Convex subscription.
Share links
Mint view-only or editable links per scene. Tokens are rotatable and revocable, and gate access without exposing your account.
Live collaboration
Start a real-time room on any scene. Per-element last-write-wins reconciliation, live cursors, and presence — durable in Convex.
Thumbnails & previews
Scenes render a PNG thumbnail on save, served through an authenticated, cache-friendly proxy route for a fast dashboard grid.
Validated boundaries
Zod schemas guard every app, storage, and client-data boundary, so malformed payloads are rejected before they reach storage.
Architecture
Tech stack
A small, modern stack where each service owns one job. Convex holds reactive metadata; R2 holds bytes; Better Auth holds identity.
Next.js 16
App Router, route handlers, proxy
React 19
Server & client components
Excalidraw 0.18
The embedded drawing canvas
Better Auth
Authentication & session management
Convex
Reactive metadata database & live rooms
Cloudflare R2
Scene bundle & thumbnail object storage
Tailwind CSS v4
Styling + shadcn / Radix UI primitives
Zod v4
Runtime validation at every boundary
Architecture
How it fits together
The browser talks to three planes: Convex for live metadata, Next.js route handlers for presigned storage access, and R2 directly for the actual scene bytes.
Browser
Next.js + Excalidraw
Identity
Better Auth
Sessions & Convex JWTs
identity · JWT
Control plane
Convex
Reactive metadata & live rooms
live subscriptions ↕
Data plane
Cloudflare R2
Scene bundles & thumbnails
presigned bytes ↕
metadata via Convex · bytes via presigned R2 URLs · identity via Better Auth
The split is deliberate. Convex is the control plane — small, reactive, strongly consistent rows that every client subscribes to. R2 is the data plane — large, opaque blobs the server never reads on the hot path. The Next.js route handlers are a thin brokerage layer: they authorise a request, then hand the browser a presigned URL so the bytes flow browser ↔ R2 directly. That keeps multi-megabyte scene payloads off both the database and the serverless function.
Architecture
Request & data flows
Three flows carry every byte in the app. Each keeps large payloads off the database and routes them browser-to-R2 over short-lived presigned URLs.
Save
Editor → presigned PUT → R2 → commit mutation → fan-out
- 01The editor debounces changes and serialises the scene to Excalidraw JSON, computing a SHA-256 content hash.
- 02Client POSTs /api/scenes/[id]/upload; the handler resolves ownership via Convex and mints a presigned R2 PUT URL.
- 03Client uploads the bundle bytes (and, separately, a rendered PNG thumbnail) straight to R2.
- 04commitSceneSave records the object key, byte size, and content hash — recomputing the expected key and rejecting any mismatch.
- 05If the content hash is unchanged the mutation is a no-op; otherwise version++ and the reactive getLibrary subscription pushes to every tab.
Load
getLibrary pointer → presigned GET → R2
- 01The editor reads the current object key from the profile-scoped getLibrary subscription.
- 02Client GETs /api/scenes/[id]/download; the route re-checks access with getSceneStorageAccess and mints a presigned R2 GET URL.
- 03Client fetches the bundle bytes directly from R2 and hydrates the canvas — bytes never pass through the Next.js server.
Thumbnail
Rendered client-side → presigned PUT → authenticated proxy
- 01On save the client renders a PNG preview and uploads it via a presigned PUT to a deterministic thumbnail key.
- 02The dashboard requests /api/scenes/[id]/thumbnail?v=<version> — an authenticated proxy that streams the PNG with an immutable cache header.
- 03Because the URL carries the scene version, a new save busts the cache automatically; a failed thumbnail upload never wipes the existing one.
The content hash earns its keep on every save
commitSceneSave compares the incoming content hash to the stored one and returns early when they match. That skips a version bump that would otherwise re-fire the getLibrary subscription and re-render every open tab — so an idle autosave costs nothing.Architecture
Data model
All metadata lives in Convex, profile-scoped and indexed for the exact access patterns the app needs. Eight tables, no joins on the hot path.
profilesSceneVault app profile keyed by the Better Auth subject. Created lazily on first write.
authSubject · timestamps
foldersNested folder tree, profile-scoped. Self-referential parentFolderId; moves are cycle-checked server-side.
profileId · name · parentFolderId · timestamps
scenesScene metadata and the authoritative pointer to current bytes: title, folder, monotonic version, R2 object keys, byte size, and content hash.
profileId · title · folderId · version · currentObjectKey · contentHash
sceneSharesView / edit share tokens per scene. Enable, disable, or rotate without touching the scene. At most one row per (scene, mode).
sceneId · profileId · mode · token · enabled
liveRoomsAt most one live room per scene. Tracks lifecycle status, epoch, hydration claim, and snapshot watermark/hash for safe GC.
sceneId · status · epoch · snapshotMaxUpdatedAt · snapshotHash
roomElementsLive working set — one row per element. Per-element last-write-wins via version / versionNonce. Tombstones converge deletes.
sceneId · elementId · data · version · versionNonce
roomSessionsServer-issued session identity. The cleartext secret is returned once by joinRoom; only its SHA-256 hash is stored.
sceneId · roomSessionId · sessionSecretHash · userId
presenceEphemeral cursor / selection presence. Name and colour are denormalised so getPresence is a single index read, no joins. Swept by TTL.
sceneId · cursorX · cursorY · selectedIds · color · lastSeenAt
collabRateLimitsToken-bucket rate limiting per (session, action). Isolated to its own row so it never contends with element or presence writes.
sceneId · roomSessionId · action · tokens
Indexes mirror the queries exactly. The dashboard's getLibrary reads folders by by_profile and scenes by by_profile_updated in parallel; share lookups hit the unique by_token index; and every collab read is a single by_scene or by_room_session scan.
Architecture
Scene storage & R2
Scene bundles and thumbnails are stored as objects in a Cloudflare R2 bucket under a deterministic, profile-scoped key layout.
users/{profileId}/scenes/{sceneId}/head/excalidraw.json # scene bundle
users/{profileId}/scenes/{sceneId}/head/thumbnail.png # PNG previewBrowsers never receive long-lived credentials. Every read and write goes through a presigned URL minted server-side with a 5-minute (300s) expiry, scoped to one exact object key and HTTP method. Because the key is derived from profileId + sceneId rather than supplied by the client, and the commit mutations recompute and re-check it, a client can never read or write outside its own namespace.
Object operations are kept simple and idempotent. Deletes remove the bundle and thumbnail unconditionally (a no-op if absent), and the duplicate flow uses a server-side R2 CopyObjectso a shared scene's bytes are cloned without round-tripping through the browser.
CORS is required for browser uploads
PUTrequests from your app origin. Configure the bucket's CORS policy before autosave will work in production.Architecture
Consistency model
Two stores, two consistency guarantees, one source of truth. Convex is authoritative for which bytes are current; R2 merely holds them.
Metadata mutations are transactional and reactive. The version, contentHash, and currentObjectKey on a scene are the single source of truth for the current bytes.
Bytes are written to R2 before the commit mutation records them. Readers always follow the Convex pointer, so they never observe a half-written or orphaned object.
A save is effectively two phases: upload bytes, then commit the pointer. If the second phase is lost, the uploaded bytes are simply never referenced — the next save overwrites the same head key — and no reader is ever exposed to them. Identical content short-circuits the commit entirely via the content hash, so the version counter only ever moves forward on a real change.
Live convergence: per-element LWW
In a live room each element converges independently. The server applies the same tie-break Excalidraw uses on the client: a higher version wins; on a tie the lower versionNonce wins; identical means no change. Mirroring reconcileElements keeps server and clients convergent, and deletes propagate as tombstones that the sweep cron later garbage-collects.
export function incomingElementWins(incoming, stored) {
if (!stored) return true;
if (incoming.version !== stored.version)
return incoming.version > stored.version; // higher version wins
if (incoming.versionNonce !== stored.versionNonce)
return incoming.versionNonce < stored.versionNonce; // tie: lower nonce wins
return false; // identical: no change
}Security
Trust boundaries
Authorization is enforced three times, independently, on every storage request — defence in depth so no single bug opens a door.
Edge — Better Auth proxy
proxy.ts gates /dashboard, /scenes, and /api/scenes by checking for a Better Auth session cookie. /share/e is deliberately public so guests can join an edit room by token. In local demo mode the proxy short-circuits entirely.
Route handler — server-derived identity
Each storage route re-derives the Better Auth session server-side, mints a fresh Convex JWT, and asks Convex which profile owns the scene. The profile id used to build the R2 key comes back from Convex — never from the request.
Convex — profile-scoped data layer
Every query and mutation independently resolves the caller via ctx.auth, maps the Better Auth subject to a SceneVault profile, and filters by profileId. Commit mutations recompute the expected object key and reject mismatches, so a forged key can't escape the caller's namespace.
const protectedPrefixes = ["/dashboard", "/scenes", "/api/scenes"];
export default function proxy(req: NextRequest) {
if (process.env.NEXT_PUBLIC_LOCAL_DATA === "1") return;
const signedIn = req.cookies
.getAll()
.some((cookie) => cookie.name.includes("session_token"));
if (isProtectedPath(req.nextUrl.pathname) && !signedIn) {
return NextResponse.redirect(new URL("/sign-in", req.url));
}
});Beyond the three layers: presigned URLs are scoped to a single key and expire in five minutes; share tokens are 24 random bytes looked up by a unique index and can be disabled or rotated instantly; and collab session secrets are stored only as SHA-256 hashes, so a database leak can't be replayed to spoof a session.
Collaboration
Live collaboration
Start a real-time room on any scene and edit together. The live working set lives in Convex so a browser crash can never lose edits.
Room lifecycle
Only the scene's profile can startRoom. The room then moves through four states as the first joiner seeds it from the durable R2 snapshot:
empty
Room started on a scene with no durable bytes yet — ready to edit from scratch.
needsHydration
A durable R2 snapshot exists and must seed the working set before edits begin.
hydrating
The first joiner has claimed seeding from R2. A stale claim (>15s) can be re-claimed.
ready
The live working set in Convex is authoritative; clients reconcile against it in real time.
Joining issues a session: joinRoom returns a one-time secret (only its hash is stored) and a presence row. Edits are diffed against last-known versions, debounced, and pushed in batches; the server reconciles each element with last-write-wins. Cursors and selections stream as throttled presence updates, denormalised so a single index read renders every collaborator.
Live presence
Cursors, selections, and who's online — denormalised for join-free reads, swept by a 15s TTL.
Session identity
joinRoom issues a secret once; only its hash is stored. Every mutation must present a match.
Durable snapshots
An elected snapshotter persists the room to R2; a cron GCs the working set only once it's saved.
Snapshots & garbage collection
Among active signed-in sessions, exactly one is elected snapshotter — the lexicographically lowest session id, so the choice is deterministic and needs no coordination. It debounces the live set back to R2 and calls markRoomSnapshot with a watermark. A room is “dirty” whenever a live element is newer than that watermark, and the sweep cron never collects a dirty room — the guarantee that no edit is lost even if every browser disconnects before snapshotting. Guests can never snapshot; persistence always requires a signed-in session.
Timings & caps
| Constant | Value | Meaning |
|---|---|---|
PRESENCE_TTL_MSPresence older than this is offline. | 15 s | Presence older than this is offline. |
HEARTBEAT_INTERVAL_MSClient presence heartbeat cadence. | 5 s | Client presence heartbeat cadence. |
CURSOR_THROTTLE_MSOutbound cursor update throttle (~10/s). | 100 ms | Outbound cursor update throttle (~10/s). |
ELEMENT_FLUSH_MSElement-broadcast debounce. | 250 ms | Element-broadcast debounce. |
SNAPSHOT_DEBOUNCE_MSSnapshot-to-R2 debounce. | 4 s | Snapshot-to-R2 debounce. |
ROOM_IDLE_GRACE_MSIdle grace before a clean room is GC-eligible. | 60 s | Idle grace before a clean room is GC-eligible. |
MAX_ELEMENTS_PER_SCENEAbove this, live mode falls back to single-user save. | 10,000 | Above this, live mode falls back to single-user save. |
MAX_BATCH_ELEMENTSElements per push / hydration batch. | 256 | Elements per push / hydration batch. |
MAX_ELEMENT_BYTESPer-element serialised cap (images live in R2). | 128 KB | Per-element serialised cap (images live in R2). |
MAX_SESSIONS_PER_ROOMHard cap on simultaneous room sessions. | 64 | Hard cap on simultaneous room sessions. |
Rate limiting
Public, token-gated mutations are throttled by a per-session token-bucket limiter persisted in collabRateLimits, isolated to its own row so it never contends with element or presence writes. Each bucket refills continuously at its rate up to a burst capacity; a call that can't spend a token is rejected rather than queued.
| Action | Refill | Burst | Effect |
|---|---|---|---|
joinRoomCaps how fast a session can (re)join, blunting reconnect storms. | 2 / s | 12 | Caps how fast a session can (re)join, blunting reconnect storms. |
pushElementsBounds element-broadcast throughput per session. | 12 / s | 24 | Bounds element-broadcast throughput per session. |
updatePresenceAbsorbs cursor bursts while still throttling sustained spam. | 20 / s | 40 | Absorbs cursor bursts while still throttling sustained spam. |
Validation bounds
Every inbound element, batch, and presence payload is validated server-side before it touches a row, so a hostile or buggy client can't bloat a room or smuggle oversized data past the working set.
| Constant | Value | Meaning |
|---|---|---|
MAX_ELEMENT_BYTESPer element, serialised — images live in R2, not the row. | 128 KB | Per element, serialised — images live in R2, not the row. |
MAX_BATCH_ELEMENTSElements per push / hydration batch. | 256 | Elements per push / hydration batch. |
MAX_SELECTED_IDSSelection ids carried in a presence update. | 1,000 | Selection ids carried in a presence update. |
MAX_NAME_LENGTHCollaborator display name, trimmed server-side. | 40 | Collaborator display name, trimmed server-side. |
MAX_ELEMENT_ID_LENGTHRejects oversized element identifiers. | 255 | Rejects oversized element identifiers. |
Operations
Failure modes
The design assumes browsers crash, networks drop, and uploads fail halfway. Here is what happens when they do.
Browser crash mid-edit
The live working set lives in Convex, and the sweep cron never collects a dirty room — so unsnapshotted edits survive even if every tab disconnects. On rejoin the room rehydrates from the R2 head plus the live set.
Snapshotter disconnects
The snapshotter is the lexicographically lowest active signed-in session, so re-election is deterministic. A pagehide handler also fires a final flush + snapshot before the tab closes.
Save commit never lands
Bytes are written to R2 before the commit mutation. If the commit is lost, the orphaned bytes are simply never referenced; the next save overwrites the same head key. Readers always follow Convex, never half-written state.
Thumbnail upload fails
commitSceneSave only advances the thumbnail pointer when a new one was uploaded, so a stale-but-valid preview is preserved. The proxy returns 404 when none exists and the grid falls back gracefully.
Hydration claimer vanishes
A hydration claim older than 15s is treated as stale and re-claimed by the next joiner, so a room can never get wedged in the hydrating state.
Stale presence & sessions
Heartbeats run every 5s against a 15s TTL. The sweep cron removes stale presence, sessions, and rate-limit rows every 60s, and GCs rooms that are both idle and fully snapshotted.
Revocation is reactive too. Disabling a share re-runs the collab queries every member is subscribed to, and the client flips to a revoked state immediately; bumping a room's epochforces every member to resync. And when Convex isn't configured, the storage routes return a clean 503 rather than crashing.
Operations
Local vs production
One codebase, two runtime modes. A single flag, NEXT_PUBLIC_LOCAL_DATA, decides whether the app talks to real services or stays entirely in the browser.
| Local demo | Production | |
|---|---|---|
| Storage | Browser localStorage | Convex + Cloudflare R2 |
| Auth | Bypassed | Better Auth sessions |
| Thumbnails | Inline PNG data URLs | Authenticated proxy route |
| Live collab | Unavailable | Full real-time rooms |
| Credentials | None required | Better Auth · Convex · R2 |
The switch is the exported shouldUseRemoteData flag: remote mode is on only when NEXT_PUBLIC_LOCAL_DATA is not 1 and the Convex URL is present. In local mode the library provider reads and writes localStorage, renders thumbnails as inline PNG data URLs, and the auth proxy short-circuits — so the Playwright suite runs the full UI with zero external services. Live collaboration depends on Convex and is therefore remote-only.
Running the full stack locally
Convex's _generatedfiles don't exist until the project is linked, so run the Convex dev server before building the backend.
# Link & run the Convex backend (creates _generated files)
pnpm exec convex dev
# In another terminal, run Next.js against your .env.local
pnpm devOperations
Production setup
Wire up the three external services, then deploy. The steps below mirror README.md.
- 1Copy
.env.exampleto.env.local. - 2Link and push Convex with
pnpm exec convex dev --once. - 3In
.env.local, setCONVEX_DEPLOYMENTplusNEXT_PUBLIC_CONVEX_URL,CONVEX_SITE_URL,NEXT_PUBLIC_CONVEX_SITE_URL, andSITE_URLorBETTER_AUTH_URL. - 4In Convex env, set
SITE_URLorBETTER_AUTH_URL,BETTER_AUTH_SECRET, and Google/GitHub OAuth credentials. - 5Create an R2 bucket and S3 API token, then fill the
CLOUDFLARE_R2_*variables. - 6Configure R2 CORS to allow browser
PUTuploads from your app origin.
Run Convex dev before deploying
_generated files and HTTP actions are not created until the project is linked and pushed. Run pnpm exec convex dev --once before deploying the backend. If /api/auth/* returns the Convex HTTP actions 404, this is the missing step.OAuth callbacks
Better Auth handles all current sign-in and session state. GitHub and Google should redirect back to the Next.js app, where app/api/auth/[...all]/route.ts forwards the request to Convex.
GitHub local callback: http://localhost:3000/api/auth/callback/github
Google local callback: http://localhost:3000/api/auth/callback/google
GitHub production callback: https://your-domain.com/api/auth/callback/github
Google production callback: https://your-domain.com/api/auth/callback/googleStore SITE_URL or BETTER_AUTH_URL, BETTER_AUTH_SECRET, and the Google/GitHub client credentials in Convex env. Keeping them only in .env.local is not enough because Convex executes the Better Auth handler.
Operations
Project structure
A conventional Next.js App Router layout, with Convex functions and shared library code split out.
app/ Next.js App Router — pages, layouts, route handlers
api/ Presigned-URL brokers & share endpoints
dashboard/ The library UI
scenes/ The embedded Excalidraw editor
share/ Public view (v) and edit (e) routes
components/ React components (editor, dashboard, collab, ui/)
convex/ Schema, queries, mutations, collab logic, crons
lib/ Storage access, R2 client, hashing, validation helpers
tests/ Playwright e2e + collab smoke suitesReference
API routes
Route handlers under app/api broker presigned storage access and shared-scene operations. They never stream scene bytes themselves (except the cache-friendly thumbnail proxy).
| Method | Route |
|---|---|
| POST | /api/scenes/[sceneId]/upload Mint a presigned R2 PUT URL for the owner's scene bundle. |
| GET | /api/scenes/[sceneId]/download Presigned R2 GET URL to load a scene's current bundle. |
| DELETE | /api/scenes/[sceneId]/storage Delete the owner's scene bundle and thumbnail objects from R2. |
| GET | /api/scenes/[sceneId]/thumbnail Authenticated, cache-friendly proxy serving the PNG preview. |
| POST | /api/scenes/[sceneId]/thumbnail/upload Presigned PUT URL for the rendered thumbnail. |
| GET | /api/share/[token]/metadata Resolve a share token to scene metadata (view or edit). |
| GET | /api/share/[token]/download Token-gated presigned R2 GET URL for a shared scene bundle. |
| POST | /api/share/[token]/upload Signed-in edit-link user gets a presigned R2 PUT URL. |
| POST | /api/share/[token]/commit Signed-in edit-link user commits the uploaded shared scene bundle. |
| GET | /api/share/[token]/thumbnail Token-gated shared thumbnail proxy with no referrer leakage. |
| POST | /api/share/[token]/thumbnail/upload Signed-in edit-link user gets a presigned thumbnail PUT URL. |
| POST | /api/share/[token]/duplicate Copy a shared scene into the signed-in user's own library. |
Reference
Environment variables
Copy .env.example to .env.local and fill in the values for the services you use. Everything except NEXT_PUBLIC_LOCAL_DATA is required for production.
| Variable | Notes |
|---|---|
CONVEX_DEPLOYMENTrequired | Convex deployment selected by the CLI. |
NEXT_PUBLIC_CONVEX_URLrequired | Convex deployment URL. |
CONVEX_SITE_URLrequired | Server-side Convex site URL used by the auth proxy. |
NEXT_PUBLIC_CONVEX_SITE_URLrequired | Convex site URL for Better Auth. |
SITE_URLrequired | Public app URL used by Better Auth callbacks. |
BETTER_AUTH_URLoptional | Alternative to SITE_URL using Better Auth's standard env name. |
BETTER_AUTH_SECRETrequired | Better Auth secret key (server). |
GITHUB_CLIENT_IDrequired | GitHub OAuth client ID. |
GITHUB_CLIENT_SECRETrequired | GitHub OAuth client secret. |
GOOGLE_CLIENT_IDrequired | Google OAuth client ID. |
GOOGLE_CLIENT_SECRETrequired | Google OAuth client secret. |
CLOUDFLARE_R2_ACCOUNT_IDrequired | R2 account id. |
CLOUDFLARE_R2_ACCESS_KEY_IDrequired | R2 S3 API access key id. |
CLOUDFLARE_R2_SECRET_ACCESS_KEYrequired | R2 S3 API secret access key. |
CLOUDFLARE_R2_BUCKETrequired | Target R2 bucket name. |
NEXT_PUBLIC_LOCAL_DATAoptional | Set to 1 for local demo mode (localStorage, no services). |
Reference
Scripts & testing
The project ships unit tests (Vitest + convex-test), end-to-end tests (Playwright, run in local demo mode), and a collaboration smoke suite.
pnpm devStart the dev serverpnpm buildProduction buildpnpm testRun Vitest unit testspnpm e2ePlaywright end-to-end suitepnpm lintESLintpnpm typechecktsc --noEmitpnpm formatPrettier writepnpm allformat · lint · typecheck · test · e2eWhat the E2E suite covers