This is a submission for the Sanity Challenge, Path Two: Vibe-Code Something Strange
What I Built
A project of mine runs on Railway: frontend, backend, websockets, Postgres and Redis in one place. Then I needed user images, avatars first, that load fast for everyone.
- Railway's storage buckets are private. Public buckets aren't supported, so every image read means a presigned URL or a proxy through my backend.
- The usual fix is object storage behind a CDN. That's another provider and another DNS surface to run. On Cloudflare, for example, keeping my own DNS and using a partial CNAME setup is a Business-plan feature.
- Even then I'd still have to build resizing, format negotiation and per-image metadata myself.
I already use Sanity for content, and a Sanity project already ships an asset store, an image pipeline, a global CDN, structured documents and realtime APIs. So I asked a narrower question: can Sanity be the image layer itself?
AssetLake is my answer:
-
@assetlake/core, a framework-neutral server SDK. My backend callsassetLake.images.upload(...). It checks the file against a policy stored as Sanity content (allowed types, size, magic bytes, dimensions), uploads it as a Sanity asset, and creates a structuredassetLakeImagerecord: owner, purpose, status. Presets likeavatar,cardandheroare Sanity documents too, so I can change them without a deploy. -
A demo app (Next.js 16 on Railway). Route Handlers are the only thing holding the write token. Browsers load every variant straight from
cdn.sanity.io. My backend is the write and auth boundary, never an image proxy. - AssetLake Console, a Sanity App SDK app inside the Sanity Dashboard. It shows a live overview, the asset list, an asset detail view with every preset applied, and live preset editing with draft and publish.
Who it's for: developers who already have a Sanity project and want public image uploads with transforms and a CDN, without bolting on a bucket, a CDN and an image service.
What it deliberately isn't: private storage (standard Content Lake assets are public by URL), an S3-compatible API, a video or generic file pipeline, or a claim that this is the cheapest way to store bytes.
Standard Content Lake assets are public by URL. AssetLake MVP is for public images only.
Demo
-
Live app: https://assetlake.ejemeniboi.com
- Demo passcode:
AJ1732. It's published on purpose. The real limits are the upload policy, quotas (5 uploads per session plus a daily cap) and request-size limits.
- Demo passcode:
- Live feed (no login): https://assetlake.ejemeniboi.com/live, where every upload appears as it lands.
- Walkthrough video: VIDEO_URL
- Console screenshots
How it works:
WRITE Browser -> authenticated app backend (Route Handler) -> @assetlake/core -> Sanity Content Lake
READ Browser -> cdn.sanity.io (image pipeline + Asset CDN)
OPS AssetLake Console (App SDK) -> Sanity Content Lake, live
Try this:
- Open
/livein one window. - Upload an avatar on
/playgroundin another. Within about 10 seconds it appears on/live. - In DevTools → Network → Img, every image request goes to
cdn.sanity.io. - The preset matrix shows one original as
avatar-sm,avatar,cardandhero. These are URL transforms, not stored thumbnails.
Code
https://github.com/AJ1732/assetlake
packages/assetlake-core is the SDK, apps/demo-web the demo, apps/asset-console the App SDK console, and packages/sanity-schema the content model. The README's "Use AssetLake with your own Sanity project" section shows how to point the SDK at your own project, with a live test proving those steps.
My Build Process
I built this in one day with Claude Code as a pair. I kept an honest build log as I went. These are the parts worth telling.
Plan first, in batches, with a locked architecture
- I started from a long handoff document with an architecture lock, and had the agent grill me on it: 13 decisions over 4 rounds (scope, demo auth, deploy target, idempotency, how judges see the console).
- The agent's first move was to propose
apps/webandservices/*from my own global "services-first" rule. I pointed it at the lock, and the lock won. - The work became batches: a scaffold, then the schema and seed, then the core contracts (frozen with a tag,
@assetlake/core@0.1.0). Three batches then ran in parallel git worktrees: the HTTP layer, the UI and the console. Integration and deploy came after, then these docs. - Parallel sessions never edited the shared docs directly. Each wrote a "return" file that the main session folded in on merge.
Where the docs corrected us
-
Dotted ids. The first idempotent id format was
assetLakeImage.<hash>. In Sanity, dotted ids are private paths that tokenless reads can't see, so the public live feed would have been empty. Ids becameassetlake-image-<hash>. -
Live Content API, not
client.listen. The plan saidlisten. Sanity's docs recommend the Live Content API for end-user frontends, andlistenignores projections, so we switched. -
Compensation. I planned to count references before deleting an orphaned asset. The asset docs say
client.deletealready refuses (409) when something references the asset. Sanity dedupes identical bytes, so that refusal is exactly the safety check I needed. -
Railway had moved on. I had a
railway.jsonready, but Railway had deprecated Config as Code, and new services can't use it at all. The config moved to.railway/railway.ts(Infrastructure as Code). I only caught it because I asked the agent whether it had checked Railway's docs for the update. It hadn't. After that I made it re-verify every external source the plan used.
Why upload moved to the backend
A Sanity write token in the browser can write anything in the dataset, and there are no scoped upload URLs for standard assets. So every write goes through my backend: a passcode session (HMAC-signed HttpOnly cookie), the policy check in core, then the upload. Reads never touch my backend.
The token-leak test drives 11 response scenarios, including an upstream error whose message contains the token, and asserts the token appears in no body or header. A secret scanner checks the repo and the browser build output.
Why assetLakeImage documents exist
A raw sanity.imageAsset tells you about bytes, not about meaning. I needed "the latest ready avatar for user X", per-session upload counts, a status lifecycle, and which application and policy accepted an upload. So every upload creates a record that references the asset. It's also what the console and the live feed list.
Things that went wrong
-
"Lazy env" was wrong. I believed env parsing was lazy, but route modules import it at load time. So
next buildneeds the full env, which mattered on Railway. We kept the fail-fast behaviour on purpose. -
An unbounded JSON body on the public session route. The first size check had its own bug: an empty
Content-Lengthbecame0and passed. Caught on re-read and covered by a test. - A quota hole. The passcode is public, so "new session, upload, delete, repeat" would never move a Sanity-side count. An in-process daily floor closes it.
-
App SDK friction:
- The app template pinned SDK v2 and Sanity UI v3 while Sanity itself had moved to v3 and v4.
- Sanity UI 4 renamed props.
- Optional
process.env.SANITY_APP_*variables throw in the browser;import.meta.envworks. -
useEditDocumentwith a path can't unset a field.
-
The secret scanner's own false positives. The spec's
sk…pattern matched Next's runtime (taskAsyncStorageInstance). TheNEXT_PUBLIC_.*TOKENpattern matched across a whole minified line on the/docspage. Both were tightened, and every hit now says which check fired. -
A test that touched the real repo. The scanner's tests run
git initin a temp folder. Inside the pre-commit hook, git exportsGIT_DIR, so thatgit initre-initialized my actual repository instead. It was harmless, but I only found it because the hook failed. Child processes now get an isolated environment, with a regression test. - Deploy with no deployment. The Railway IaC apply set every build setting correctly but never connected the GitHub branch: Railway had no access to my private repo. The API showed an empty trigger list.
-
The live feed could be two minutes late. With
/livealready open, an upload shows in about 10s (the matching live event arrives about 6s after the write). But opening/livejust after uploading could show a stale list for up to 75s, because the first fetch went through the API CDN cache. First loads now read the uncached API. An E2E test reproduces the exact order that had hidden it.
Scope cuts
Workflows (a review lifecycle) and Functions were planned and deliberately deferred. AssetLake isn't on npm yet; publishing @assetlake/core and an assetlake CLI (init, upload, doctor) is the next step, so anyone with a Sanity project can use it with their own credentials.
What working with an agent was like
The agent was fast and thorough, and wrong in confident ways I had to catch:
- an invented branch-naming scheme;
- a README command that couldn't run;
- a deprecated config format;
- a regex that flagged Next's own code.
What worked was making it propose before touching files, making it re-verify docs instead of trusting memory, and keeping all deterministic checks in code (tests, the scanner, the boundary rules) rather than in its judgment.
Sanity Project Details
-
Project ID:
oshzwvjy -
Public dataset:
production - Public dataset URL: https://oshzwvjy.api.sanity.io/v2026-10-04/data/query/production?query=*[_type%20match%20%22assetLake*%22]
-
App SDK console app id:
otc94a70i1qncgk3i3hmzosp




Top comments (0)