Public images only. A Sanity image asset is readable by anyone who has its URL, so AssetLake is for avatars, covers, and other images meant to be seen.

1. Create the server client

Server only. The write token never gets a NEXT_PUBLIC_ prefix and never reaches a browser.

lib/server/asset-lake.ts
import { createAssetLake } from "@assetlake/core";

export const assetLake = createAssetLake({
  projectId: "oshzwvjy",
  dataset: process.env.SANITY_DATASET!,
  apiVersion: "2026-10-04",
  token: process.env.SANITY_WRITE_TOKEN!, // server only
});

2. Upload from your route handler

Core checks the declared type, size, and magic bytes against the policy before anything reaches Sanity. A repeated Idempotency-Key returns the existing record.

app/api/assets/images/route.ts
const image = await assetLake.images.upload({
  body: new Uint8Array(await file.arrayBuffer()),
  filename: file.name,
  contentType: file.type,
  applicationId: "assetlake-application-campus-demo",
  purpose: "avatar",
  entity: { type: "user", id: session.userId },
  actorId: session.userId,
  idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined,
});

3. Resolve preset URLs on the server

Presets live in Sanity, so an edited preset applies without a redeploy (cached for 60 seconds). responsive() uses the stored hotspot and returns a bounded srcSet of 256, 512, and 768w.

server component
const avatarUrl = await assetLake.images.url(image.id, { preset: "avatar" });
const card = await assetLake.images.responsive(image.id, { preset: "card" });
// card: { src, srcSet, sizes, width, height, lqip }

4. Render a plain <img>

No image optimizer in between: the browser requests cdn.sanity.io directly. Show the LQIP while it loads, if Sanity returned one.

AssetLakeImage.tsx
<img
  src={card.src}
  srcSet={card.srcSet}
  sizes={card.sizes}
  width={card.width ?? undefined}
  height={card.height ?? undefined}
  alt="Project cover"
  style={card.lqip ? { backgroundImage: `url("${card.lqip}")` } : undefined}
/>

Building URLs in the browser

@assetlake/core/url is browser safe: it needs only the public project id and dataset. Prefer server-resolved URLs for stored records so hotspots apply.

client component
import { createImageUrls } from "@assetlake/core/url";

const urls = createImageUrls({ projectId: "oshzwvjy", dataset: "production" });
const src = urls.buildUrl(image.assetId, {
  width: 256,
  height: 256,
  fit: "crop",
  autoFormat: true,
});

HTTP contract

The demo backend's upload endpoint. Every response uses the same envelope.

POST /api/assets/images
multipart/form-data: file, purpose ("avatar"), alt (optional)
Idempotency-Key: <uuid>            optional, safe retries

201 { "success": true,  "data": { id, assetId, url, mimeType, size, width, height, aspectRatio, lqip, blurHash, status } }
4xx { "success": false, "error": { "code": "FILE_TOO_LARGE", "message": "..." } }

401 UNAUTHENTICATED · 400 UNSUPPORTED_IMAGE_TYPE · 413 FILE_TOO_LARGE · 429 RATE_LIMITED