DEV Community

Cover image for Have your own Lake of Assets
AJ
AJ

Posted on

Have your own Lake of Assets

Sanity Challenge Path Two Submission

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 calls assetLake.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 structured assetLakeImage record: owner, purpose, status. Presets like avatar, card and hero are 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

Console Overview

Console Assets

Console Asset View

Console Presets

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
Enter fullscreen mode Exit fullscreen mode

Try this:

  1. Open /live in one window.
  2. Upload an avatar on /playground in another. Within about 10 seconds it appears on /live.
  3. In DevTools → Network → Img, every image request goes to cdn.sanity.io.
  4. The preset matrix shows one original as avatar-sm, avatar, card and hero. 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/web and services/* 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 became assetlake-image-<hash>.
  • Live Content API, not client.listen. The plan said listen. Sanity's docs recommend the Live Content API for end-user frontends, and listen ignores projections, so we switched.
  • Compensation. I planned to count references before deleting an orphaned asset. The asset docs say client.delete already 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.json ready, 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 build needs 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-Length became 0 and 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.env works.
    • useEditDocument with a path can't unset a field.
  • The secret scanner's own false positives. The spec's sk… pattern matched Next's runtime (taskAsyncStorageInstance). The NEXT_PUBLIC_.*TOKEN pattern matched across a whole minified line on the /docs page. Both were tightened, and every hit now says which check fired.
  • A test that touched the real repo. The scanner's tests run git init in a temp folder. Inside the pre-commit hook, git exports GIT_DIR, so that git init re-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 /live already open, an upload shows in about 10s (the matching live event arrives about 6s after the write). But opening /live just 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

Top comments (0)