Public image infrastructure built from Sanity primitives. Your app uploads images through its own backend. AssetLake checks them against a policy you store as content, records each image as a structured document, and browsers load resized, cropped, format-negotiated variants straight from Sanity's Asset CDN.
Standard Content Lake assets are public by URL. AssetLake MVP is for public images only.
- Live demo: https://assetlake.ejemeniboi.com (uploads need the demo passcode published in the DEV post)
- Live feed: https://assetlake.ejemeniboi.com/live (every upload, as it lands, through a tokenless Live Content API client)
- Sanity project:
oshzwvjy, public datasetproduction: query the AssetLake documents
This started as an infrastructure question on a Railway stack (frontend, backend, websockets, database, Redis) that needed durable storage and fast public delivery for user images:
- Railway's storage buckets are private. Public buckets are not supported, so serving images means presigned URLs or proxying every read through the backend.
- Putting a CDN in front of object storage adds another provider and another DNS surface. On Cloudflare, for example, a partial CNAME setup is only on Business and Enterprise plans.
- Both paths still leave resizing, format negotiation and per-image metadata to build yourself.
A Sanity project already has an asset store, an image pipeline, a global CDN, structured content and realtime APIs. AssetLake asks whether those can be the image layer, and wraps them in one server SDK. The backend stays the write and auth boundary. It never becomes an image proxy.
| Feature | How AssetLake uses it |
|---|---|
| Content Lake assets | Every upload is a sanity.imageAsset, made with @sanity/client assets.upload("image", ...) from the server |
| Structured schema documents | assetLakeImage (one record per upload: owner, purpose, status), assetLakePolicy (allowed types, size, dimensions), assetLakePreset (named transforms), assetLakeApplication |
| Image pipeline | Presets become URL parameters (w, h, fit, q, auto=format). No thumbnails are stored |
| Asset CDN | Browsers fetch every image from cdn.sanity.io. The demo never proxies an image or uses /_next/image |
| Live Content API | The public /live feed (tokenless) and the console update without polling |
| App SDK | AssetLake Console runs inside the Sanity Dashboard: an overview, live assets, asset detail with every preset, live preset editing with draft and publish, and a Review Queue |
| Workflows (early access) | An image review lifecycle: uploads held for review are claimed, then approved or rejected, in the console's Review Queue (image-review) |
| Functions | Two Document Functions start a review when a held image is created and drain the workflow's queued effect, which applies the decision through core with a Blueprint robot token |
Review governs lifecycle, not confidentiality. A held or rejected image is already public at its cdn.sanity.io URL; review only decides whether apps use it.
flowchart LR
B[Browser] -- "POST /api/assets/images (session cookie)" --> R["demo-web Route Handler<br/>(holds the write token)"]
R -- "@assetlake/core: policy check, upload, record" --> S[("Sanity Content Lake<br/>assets + assetLake* docs")]
S --> C["cdn.sanity.io<br/>image pipeline + Asset CDN"]
B -- "GET image variants" --> C
B -. "Live Content API, tokenless" .-> S
O["AssetLake Console<br/>(App SDK, Sanity Dashboard)"] -- "live queries, preset edits" --> S
WRITE Browser -> authenticated app backend -> @assetlake/core -> Sanity Content Lake
READ Browser -> cdn.sanity.io
OPS AssetLake Console (App SDK) -> Sanity Content Lake, live
An upload, in order:
- A replay check runs first: the same actor sending the same
Idempotency-Keygets the existing record back. - The application is resolved, then its default policy.
- The request is rejected if its declared type isn't allowed, the body is too large, or the bytes don't match the declared type (magic bytes via
file-type). Nothing reaches Sanity. - The asset is uploaded, its dimensions are checked, and the
assetLakeImagerecord is created. - If anything fails after the upload, the asset is deleted, unless another record still references it (Sanity dedupes identical bytes and answers 409).
The demo adds a passcode session (HMAC-signed HttpOnly cookie), 5 uploads per session, a global daily cap, and a 5 MB limit for JPEG, PNG and WebP.
| Path | Package | Role |
|---|---|---|
packages/assetlake-core |
@assetlake/core |
The SDK on npm: framework-neutral upload, policy, presets, setup, URLs (README) |
packages/assetlake-cli |
@assetlake/cli |
assetlake init / upload / url / delete / doctor for your own project (README) |
packages/sanity-schema |
@assetlake/sanity-schema |
Content model, schema deploy, TypeGen (constants come from core) |
packages/image-review |
@assetlake/image-review |
Workflows definition, effect handler and the two Sanity Functions for the review lifecycle (private) (README) |
apps/demo-web |
@assetlake/demo-web |
Next.js 16 demo: Route Handler upload boundary, playground, live feed, docs (README) |
apps/asset-console |
@assetlake/asset-console |
Sanity App SDK operations console (README) |
examples/express |
(not in the workspace) | Minimal Express 5 server on the npm package (README) |
examples/nextjs |
(not in the workspace) | Minimal Next.js 16 app on the npm package (README) |
Stack: Node 24, pnpm workspaces, TypeScript, Next.js 16 (App Router), React 19, @sanity/client 8, Sanity App SDK 3, Vitest, Playwright. demo-web runs on Railway (.railway/railway.ts); the console is deployed with sanity deploy.
The gate lane needs no credentials:
nvm use # Node 24 (.nvmrc)
corepack enable
pnpm install
pnpm check # lint + typecheck + format check
pnpm test # gate lane: Vitest, every package, no network
pnpm test:slow # slow lane: secret-scanner script, ESLint config and typed lint
pnpm typecheck:examples # typechecks examples/* against the packed workspace coreThe build, the live lane, E2E and the dev server need a Sanity project and an Editor token. next build imports the server env module, which fails fast when variables are missing. Copy .env.example to .env.local at the repo root, fill it in, and link it for Next, which only reads env files from the app directory:
ln -s ../../.env.local apps/demo-web/.env.local
pnpm build
SANITY_DATASET=test pnpm test:live # eval lane against a real dataset
pnpm test:pack # pack lane: npm-installed tarballs, plain node
pnpm test:examples # boots examples/* on the packed tarball, uploads to `test`
pnpm seed # demo setup documents (create-if-missing)
ASSETLAKE_DEMO_PASSCODE=... pnpm test:e2e # Playwright; stop pnpm dev first
pnpm dev # http://localhost:3000
SANITY_WRITE_TOKEN=... ASSETLAKE_SESSION_SECRET=... ./scripts/scan-secrets.sh # secret scan, after pnpm buildTo point these at your own project rather than the demo's, see "Use AssetLake with your own Sanity project" below.
- CI (
.github/workflows/ci.yml, every push tomainordevand every PR): jobgaterunspnpm check,pnpm testandpnpm test:slowwith no secrets. Jobexamplestypechecks both examples against the packed core..github/workflows/lanes.ymlruns the pack, example, live and API E2E lanes againsttestwhen started by hand (Actions, Run workflow), with secrets held by thesanity-testenvironment. - Gate lane (
pnpm test): about 650 Vitest tests over core, the schema, demo-web's HTTP and session layer, the console, the CLI and the review workflow. It uses in-memory fakes and generated images, with no network and no binary fixtures. The pre-commit hook runs lint-staged plusvitest relatedon the staged files only; CI runs the full suite. - Slow lane (
pnpm test:slow): secret-free tests that need seconds rather than milliseconds: the secret scanner's shell script against temp git repos, and the ESLint config (Next rules scoped to demo-web, theuseEffectban inapps/, type-awareno-floating-promisesandno-misused-promises). - Live lane (
pnpm test:live): runs against thetestdataset. It covers the full upload chain (upload, record, transform, CDN fetch, idempotent replay, delete), the bring-your-own-project setup, the CLI end to end (inittwice,doctor,upload,url), and the review workflow (hold, claim, approve or reject, drain, and a check that workflow documents stay out of tokenless reads). - Pack lane (
pnpm test:pack): packs@assetlake/coreand@assetlake/clithe waypnpm publishdoes, installs the tarballs with npm in an empty project, imports every core subpath with plainnode, runs theassetlakebin, and secret-scans the tarballs. - Example lane (
pnpm test:examples): copiesexamples/expressandexamples/nextjs, installs each with npm and the packed core tarball, typechecks or builds it, starts it, then uploads, builds preset URLs, uploads from a URL and deletes through its HTTP routes againsttest. - E2E (
pnpm test:e2e): Playwright. The UI lane writes toproduction(what/liveshows) and the API lane writes totest. It checks that every image request goes tocdn.sanity.io, and that uploads appear on/livewithout a reload, including when/liveis opened after the upload. Every test upload is deleted afterwards. - Secret scan (
scripts/scan-secrets.sh): checks the repo and the browser-served build output for the token values and token-shaped strings. It prints file paths only.
- Standard Content Lake assets are public by URL. AssetLake MVP is for public images only.
- The Sanity write token lives only in server code (
apps/demo-web/lib/server/*, allserver-only). An ESLint rule and a gate test keep it out of browser bundles, and the secret scan checks the build output. - Responses never include upstream error text. Logs redact token, authorization, secret, password and cookie keys at any depth.
- The demo passcode is published on purpose. The real limits are the policy, the magic-byte check, per-session and daily quotas, and the request-size limits.
- The console holds no token. It acts as the signed-in Dashboard user, under that user's project role.
- Review decisions reach images only through core's
transitionStatus, run by a Sanity Function with a Blueprint robot token: onlyreview -> readyorreview -> rejected, only for allowlisted reviewers, and only if the record hasn't changed since it was read. There is no public review endpoint.
- Public images only. Standard Content Lake assets are not private.
- Not S3-compatible. AssetLake is an image-service abstraction, not a drop-in S3 protocol replacement.
- No custom asset domain. Images are served from
cdn.sanity.io. Custom asset domains are an Enterprise add-on. - Deletion is not instant revocation. CDN caches may keep serving a deleted asset for a while.
- The cost profile differs from object storage. Sanity was chosen for its combined capabilities and developer experience, not for the lowest raw storage price. Usage counts against your plan's asset and bandwidth quotas.
- Workflows is early access. The review lifecycle pins
@sanity/workflow-*0.36.0; 0.x minors can break, and the engine's own checks are advisory. The enforced rules live in core'stransitionStatusand in Sanity project roles. - A review status doesn't make an uploaded asset confidential. Its URL is public from the moment it's uploaded.
- No generic file or video pipeline. The MVP is images only.
- No anonymous public uploads in the demo. A passcode session and quotas are required.
- No fabricated analytics. The console shows only what Sanity documents hold, for example "original bytes of listed assets", not bandwidth.
- Judges can't open the console without Sanity project membership. It's shown through screenshots and the walkthrough video.
- The quota floor counters are in process. demo-web runs one replica, and the counters reset on every redeploy (the Sanity-side counts still apply).
- The console has no Playwright coverage, because it runs inside the authenticated Sanity Dashboard.
/playgroundmakes about 9 small Sanity reads per render. Batching them needs a core API change.- Presets resolve by slug across a dataset, not per application. This is by design (see the bring-your-own section).
- On the custom domain, CSRF protection (
SameSite=Lax) relies on every*.ejemeniboi.comsubdomain being trusted.
AssetLake is not a hosted service. Your Sanity project is the bucket: your server holds the token, the images and their records live in your dataset, and browsers load them from cdn.sanity.io/images/<yourProjectId>/<dataset>/.... Nothing goes through this repo's demo or its project.
What you get compared with S3 or R2: uploads that are checked against a policy you store as content (types, size, magic bytes, dimensions), structured records per image, and resized, cropped, format-negotiated variants from named presets, with no image server of your own. What you don't get: private files (assets are public by URL), quotas beyond your Sanity plan's asset and bandwidth limits, or transforms for non-images. Deleting an asset does not instantly purge CDN caches.
- A Sanity project with a dataset. AssetLake is for public images: asset URLs are public whatever the dataset's visibility.
- A token with the Editor role, created in sanity.io/manage under API, Tokens. Keep it in your server's environment only. Never prefix it with
NEXT_PUBLIC_and never send it to a browser.
Uploads resolve an application, then its default policy; URLs resolve presets by slug. The CLI creates all three (Node 24):
export ASSETLAKE_TOKEN=... # Editor token; the CLI never takes it as a flag
export ASSETLAKE_PROJECT_ID=abc123
export ASSETLAKE_DATASET=production
npx @assetlake/cli init --slug my-app # policy, 4 presets, assetlake-application-my-app
npx @assetlake/cli doctor --slug my-app # token, write access, visibility, setup, CORSinit is idempotent and never overwrites existing documents, so presets edited later in the Studio survive a rerun. To do the same from code, use createSetupPlan and assetLake.setup.ensure (core README, "Set up a project").
Presets are dataset-global. A preset slug resolves across the whole dataset, and preset ids are assetlake-preset-<slug>, so every application in a dataset shares the avatar preset. Give presets distinct slugs when apps need different sizes. Deploying the schema in packages/sanity-schema is optional: the Content Lake doesn't need it; it's for Studio editing, TypeGen and the console.
npm i @assetlake/coreimport { createAssetLake } from "@assetlake/core";
const assetLake = createAssetLake({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
apiVersion: "2026-10-04",
token: process.env.SANITY_WRITE_TOKEN!,
});
const image = await assetLake.images.upload({
body: bytes, // Uint8Array
filename: "photo.png",
contentType: "image/png",
applicationId: "assetlake-application-my-app",
purpose: "avatar",
entity: { type: "user", id: userId },
actorId: userId,
});
const avatarUrl = await assetLake.images.url(image.id, { preset: "avatar" });Any server works the same way: Express, Fastify, Next.js Route Handlers, workers, scripts. Browsers build variant URLs with @assetlake/core/url and never need the token. Two complete, tested starting points: examples/express and examples/nextjs. Or upload from a terminal: npx @assetlake/cli upload ./photo.png --app assetlake-application-my-app --preset avatar. See the core and CLI READMEs for the full API.
The live lane runs this path against a real dataset (packages/assetlake-core/tests/live/byo-project.live.test.ts and packages/assetlake-cli/tests/live/cli.live.test.ts), and the pack lane installs the packed tarballs in an empty project.
Sanity has no presigned uploads (every Assets API call needs a token), so browsers can't upload to Sanity directly. To keep large files off your server, upload them to your own bucket with a presigned PUT, then pass a presigned GET URL to assetLake.images.uploadFromUrl. Sanity fetches the file itself. It is off until you list your bucket's host in remoteUploads.allowedHosts, and its policy checks run after the fetch, so a rejected file is briefly public before AssetLake deletes it. Details in the core README.
A fork needs no code changes. Every app, script and config reads its project from the environment and falls back to the demo's (oshzwvjy / production). Invalid values fail at startup or build, naming the variable.
| What | Variables | Read when |
|---|---|---|
demo-web (server, and /live via props) |
SANITY_PROJECT_ID, SANITY_DATASET |
next build and next start |
pnpm seed |
SANITY_PROJECT_ID, SANITY_DATASET |
each run |
| Asset console | SANITY_APP_PROJECT_ID, SANITY_APP_DATASET |
sanity build / deploy |
| Console deploy identity | ASSETLAKE_CONSOLE_ORGANIZATION_ID, ASSETLAKE_CONSOLE_APP_ID |
sanity deploy |
| Schema deploy and TypeGen | SANITY_STUDIO_PROJECT_ID, SANITY_STUDIO_DATASET |
Sanity CLI |
demo-web sends only the project id and dataset to the browser, as a Server Component prop: there is no NEXT_PUBLIC_ variable. /live and /docs are rendered at build time, so a project change needs a rebuild. On Railway, set SANITY_PROJECT_ID in .railway/railway.ts and run railway config apply. The console's details are in its README.
SANITY_PROJECT_ID=<your project> pnpm seed # the demo's application, policy and presets
SANITY_PROJECT_ID=<your project> pnpm dev