We’re working on bringing Naji — the horse behind the name — home to Jacksonville.Bringing Naji home to Jacksonville.Meet Naji

Architecture · as of v9.27, 10-11-2026

One half is the editor. The other half is optional.

Everything inside the solid box runs in your tab. Cut the dashed box off entirely and what’s left is still a complete image editor — that isn’t a fallback mode, it’s the free editor everyone gets.

Show the architecture for one kind of user

Everything, including the parts that aren’t built yet.

System map

Browserno server in the edit path · one 893 KB engine

UI plane

React 19 · TypeScript · Vite · Tailwind v4

  • AppShell — composition and layout only
  • Tool registry — 5 modules registered; routing still hand-wired in AppShell
  • Session hooks — image · selection · canvas · mask · resize & compress · shapes
  • 11 Zustand stores — UI · tool · gallery · annotation · guides · perspective · text box · batch crop · batch group · history branches · SVG source

Compute plane

where the pixels are touched

  • Engine worker — the engine and the canvas both live here, off the main thread. Main-thread blocking per heavy op: 129–137 ms → 0.
  • WASM engine — stamp_tool: kernels, TileBuffer, OpLog, PatchMatch
  • Local describer — names a photo from what’s in it, inside the engine. No account, no per-image cost.
  • Codec worker — WebP/JPEG encode · gallery thumbnails
  • Fonts — three families served as static files, registered with the engine at runtime
  • rayon worker pool — tried and dropped: 8–31× slower than the single-threaded kernel
  • WebGPU backend — one blur kernel and a self-test; nothing on the pixel path

Persistence plane

IndexedDB · Dexie

  • originals — content-addressed (SHA-256), immutable
  • opLogs + keyframes — the op format is at v9
  • gallery manifest
  • renderCache — disposable

Plus localStorage for lightweight prefs, and a sync layer over both: a BroadcastChannel keeps every tab on this device in step, and — only when signed in — Convex carries the same three documents to your other devices. Your photos are not in it. A precache-only service worker is written and tested and still ships dark: it is blocked on eviction reach, not on the precache (ADR-049).

Annotation rendering has repeatable benchmarks at 10, 100 and 500 annotations. An opt-in dirty-region prototype is compared with full-frame and tiled rendering, including pixel parity and memory costs. It is disabled in the production editor.

Networkoptional, additive only · the only place a photo of yours can travel

Clerk

identity

Convex

settings across devices · entitlements · gallery

AI proxy → Replicate

the server holds the provider key

Share delivery

ShareViewer · view counts, timestamps only

What’s in each plane

Three words, kept apart since v8.90: a tier is what an account paid for, a role is what a person is trusted to do, and an entitlement is what this session may use. The server decides all three.

Client layer — runs entirely in the browser

React App

Vite + React 19 · Vercel static SPA

Canvas Engine

Engine worker · zero-copy blit to an OffscreenCanvas

Zustand State

11 stores · atomic selectors · prefs persisted to IndexedDB

WASM processing layer — client-side Rust, one binary

core · layer

ImageBuffer · layer stack · composite / mask

paint · effects

Brush / eraser / mask · blur / pixelate / redact

annotations · selection

Live text & shape overlays · magic-wand, edge-aware and lasso selection

stamp · transform

Clone brush · flip / rotate / resize / crop

filters

Brightness · contrast · saturation · shadows / highlights · sharpen · gaussian blur

levels · presets · adjust

Black / white / midtones · a preset is a stack of filters · Adjustments worked out from an untouched copy · one shared preview slot

perspective

Four-corner warp for shapes, text and pixels · homography solved in Rust

drawing · text · fonts

Arrows / shapes / bézier · 3 typefaces, registered at runtime, rasterized in Rust

describe

Local image description — names a photo from its content, no account

codec · history

PNG encode (Rust) · undo snapshots · op log at format v11

ops · tiles · patchmatch

Op log · tile buffer · Magic Eraser fill

simd

v128/f32x4 kernels · scalar fallback

Identity & entitlement — Clerk decides who; the server decides what

Clerk Auth

identity provider

Logged out

entitlement: none · anonymous · 12 photos

Logged in

entitlement: free · 24 photos

Paid

entitlement: paid · pro or team tier · Stripe

Admin

a role, not a tier — entitled to paid, from ADMIN_EMAILS on the server

Convex functions — signed-in only

users.ts

account row · me() with tier, role and entitlement

entitlement.ts

the one ladder: none → free → paid · a preview may only take away

photoEdits.ts

save / getEdit

ai.ts

dispatch to Replicate · 3 models wired

aiJobs.ts

job status (useQuery) · daily and monthly caps

shares.ts

public share links · pause, view cap, expiry

textHistory.ts

recent texts

sync.ts

settings across devices · compare-and-set on rev

userColors.ts

saved color palette · 32 per user

stripe.ts · subscriptions.ts

checkout / portal · plan status

http.ts

webhook router · Replicate + Stripe, HMAC-verified

Storage

IndexedDB (Dexie)

Originals · SHA-256 content-addressed · your machine

Convex File Storage

Edit archives · shares · AI frames · a server

AI — Replicate, paid only

cjwbw/rembg

background removal

text-extract-ocr

text extract

remove-object

object removal (masked, LaMa)

Real-ESRGAN

4× upscale

soon

Event handlers — webhooks

Stripe webhook

subscription changes → subscriptions

Replicate webhook

AI complete → status + guarded reference cleanup

Clerk sign-in isn’t a webhook here — the client calls users.upsert once Convex’s own auth bridge comes up, and that is what actually creates the users row. Who is an admin is read from ADMIN_EMAILS on the deployment — never from the browser.

What the server keeps, table by table.

Every table the app reads or writes, with its fields and indexes. Each row hangs straight off users, keyed by the editor’s own photoKey. Never a pixel: edited images stay in the browser that made them.

usersClerk-synced

PK_id
Id<'users'>
UQclerkIdidx
string
emailidx
string?
name
string?
avatarUrl
string?
tier
'free' | 'pro' | 'team'
dailyUsage
number
usageResetAt
number
monthlyUsage
number?AI passes this month
monthResetAt
number?
settings
string?legacy prefs blob — see sync_docs
settingsHash
string?legacy, SHA-256 of the above
createdAt
number
updatedAt
number

by_clerkIdby_email

subscriptionsStripe

PK_id
Id<'subscriptions'>
FKuserIdidx
Id<'users'>
stripeCustomerIdidx
string
UQstripeSubId
string
plan
'pro' | 'team'
status
'active' | 'canceled' | 'past_due'
currentPeriodEnd
number
cancelAtPeriodEnd
boolean

by_userIdby_stripeCustomerIdby_stripeSubId

photo_edits

PK_id
Id<'photo_edits'>
FKuserId
Id<'users'>
photoKey
stringthe editor's own photo id
FKstorageIdidx
Id<'_storage'>binary canvas archive
canvasW
number
canvasH
number
updatedAt
number

by_storageIdby_userId_photoKey

Real per-photo edit persistence path (useEditPersistence.ts)

recent_texts

PK_id
Id<'recent_texts'>
FKuserId
Id<'users'>
text
string
fontSize
number
fontFamily
string?
fontWeight
'normal' | 'bold'
textColor
string
usedAt
number

by_userIdby_userId_usedAt

Text-tool history, per signed-in user

sync_docs

PK_id
Id<'sync_docs'>
FKuserIdidx
Id<'users'>
key
string'prefs' | 'ui' | 'tools'
value
string | nullcanonical JSON; null once forgotten
rev
numberserver-assigned; a write must name the one it read
updatedAt
number
format
numberdocument version of the build that wrote it

by_userId_key

Settings and remembered UI choices, the same on every signed-in device — never pixels

user_colors

PK_id
Id<'user_colors'>
FKuserIdidx
Id<'users'>
color
string#rrggbb or #rrggbbaa, lowercase
createdAt
number

by_userIdby_userId_createdAt

The saved “+” palette — capped at 32 per user, newest first

sharespublic read

PK_id
Id<'shares'>
tokenidx
stringunguessable, public lookup key
FKuserIdidx
Id<'users'>
FKstorageIdidx
Id<'_storage'>flattened canvas PNG
canvasW
number
canvasH
number
title
string?
views
number
createdAt
number
maxViews
number?reaching it PAUSES the link, never deletes
expiresAt
number?ms epoch — stop serving at or after this
pausedAt
number?set by the owner's Pause, cleared by Resume
expiredAt
number?written AT expiresAt so a cached query re-runs
lastViewedAt
number?

by_storageIdby_tokenby_userId

Public, no-auth read — anyone with the link can view or download

share_viewsnew · timestamps only

PK_id
Id<'share_views'>
FKshareIdidx
Id<'shares'>
at
numberms epoch

by_shareId

Timestamp only — no IP, no user agent, no viewer id. One row per counted view, deleted with its share

ai_jobsReplicate

PK_id
Id<'ai_jobs'>
FKuserIdidx
Id<'users'>
photoKeyidx
string
type
'rembg' | 'upscale' | 'inpaint' | 'ocr' | 'alt' | 'generate'
status
'pending' | 'running' | 'done' | 'failed'
replicateIdidx
string?
inputStorageIdidx
Id<'_storage'>?
maskStorageIdidx
Id<'_storage'>?inpaint mask
outputStorageIdidx
Id<'_storage'>?
referenceStorageId1idx
Id<'_storage'>?generation reference 1
referenceStorageId2idx
Id<'_storage'>?generation reference 2
referenceStorageId3idx
Id<'_storage'>?generation reference 3
output
JsonValue?non-image result, e.g. OCR text
error
string?
startedAt
number?
completedAt
number?
createdAt
number

by_inputStorageIdby_outputStorageIdby_maskStorageIdby_referenceStorageId1by_referenceStorageId2by_referenceStorageId3by_userIdby_userId_photoKeyby_replicateIdby_status

Keyed by photoKey, not a server-side image row — the Replicate webhook updates status

Entity relationships

  • users 1 ─ 1 subscriptions
  • users 1 ─ ∞ photo_edits
  • users 1 ─ ∞ recent_texts
  • users 1 ─ ∞ sync_docs
  • users 1 ─ ∞ user_colors
  • users 1 ─ ∞ shares
  • shares 1 ─ ∞ share_views
  • users 1 ─ ∞ ai_jobs

Real-time

useQuery hooks update when data changes. No polling — it is what carries a setting from a phone to a laptop.

Row-level auth

ctx.auth in mutations, plus query filters for user-scoped data. Entitlement is computed server-side from tier + role.

File storage

Blob storage for edit archives, share snapshots and AI frames via storage.getUrl().

Webhooks

Replicate and Stripe post back to convex/http.ts, HMAC-verified. A scheduled shares.expire stops a link at its end date.

Why draw it this way

Because the boundary is the product. An editor that needs a server is an editor that can be switched off, rate-limited, or quietly trained on. The dashed box buys you sync, sharing and the AI passes — and it is the only place a photo of yours can travel. Signed out, you never cross it.

The map above is drawn by hand from system-architecture.mermaid, the flowchart the repo ships. Render the original wherever you like.

Download the .mermaid sourceRead the full architecture doc

flowchart TB · 121 lines · the dashed subgraph is the plane you can cut