Images, optimized. Stored anywhere.
Resize, compress and convert, then hand the result to whatever storage you already use. A core with no dependencies, and everything heavy behind an interface you control.
npm install @lens-image/core sharpFig. 01 · Measured, not mocked
The 11 images flying past were optimized by Lens, 7.2 MB down to 660 KB, 91% smaller.
Everything that happens
to an upload, by name.
- Sniff format from bytes
- Reject decompression bombs
- Resize to 1200w
- Encode AVIF
- Keep WebP if AVIF fails
- Strip EXIF
- Apply EXIF orientation
- Never upscale
- Upload to S3
- Upload to R2 or MinIO
- Write atomically to disk
- Sign Cloudinary upload
- Hash content into the key
- Retry with jitter
- Refuse path traversal
- Build srcset
- Emit a thumbnail
- Skip unchanged (opt-in cache)
- Collect warnings, not exceptions
- Encode four at a time
- Hand back picture sources
- Validate in the browser
Wire it up as a pipeline.
Every image goes through the same stages: read, sniff, validate, plan, encode, store. Swap the engine or the adapter and nothing else moves.
- Source: a file path, Buffer, URL or stream becomes bytes.
- Validate: the format is sniffed from the bytes, and size and pixel limits are checked before any decoder runs.
- Encode: the engine, sharp by default, resizes and encodes each variant, with metadata stripped and no upscaling.
- Formats: each requested format, such as AVIF, WebP and JPEG, is produced independently.
- Adapter: every variant is stored through your storage adapter, for example S3.
A lighter web,
by default.
91%
Fewer bytes
Measured across the 11 photographs in the hero: 7.2 MB down to 660 KB.
0
Runtime dependencies
In @lens-image/core. Installing it adds one package to your lockfile, and CI enforces it.
3
Storage adapters
S3, filesystem and Cloudinary ship today. Or forty lines of your own.
The 11 hero photographs, total size
Optimized by Lens with LocalAdapter
From install to srcset
in four steps.
Step 1: Install
The core is one package with nothing underneath it. The codec is yours to choose.
In your lockfile
- One package: @lens-image/core has zero dependencies
- sharp is an optional peer, installed by you and visible there
To start
- Node 18.19 or newer
- No adapter needed: without one, Lens hands the bytes back
npm install @lens-image/core sharpoptimizer.tsts import { ImageOptimizer } from '@lens-image/core'; // No adapter yet? Fine. Lens runs in process-only mode. const optimizer = new ImageOptimizer();Step 2: Pick an adapter
Storage is an interface with one required method. Choose where the files land.
Ships today
- S3, and R2, MinIO, Spaces or B2 through an endpoint
- Filesystem, with atomic writes
- Cloudinary, signed over plain fetch
Or your own
- One required method: upload()
- Retries, concurrency limits and error wrapping are handled for you
npm install @lens-image/adapter-s3 @aws-sdk/client-s3optimizer.tsts import { S3Adapter } from '@lens-image/adapter-s3'; const optimizer = new ImageOptimizer({ adapter: new S3Adapter({ bucket: 'my-images', region: 'us-east-1' }), });Step 3: Optimize
One call produces every format at every size, uploads them, and returns the URLs.
Per call
- Every format at every size, planned up front
- Formats fail independently: a failure is a warning
By default
- Metadata stripped, EXIF orientation applied
- Never upscales, never trusts a file extension
upload.tsts const result = await optimizer.optimize({ source: './photo.jpg', quality: 80, formats: ['webp', 'jpg'], sizes: [{ width: 1200 }, { width: 600 }, { width: 300 }], });Step 4: Ship the srcset
The result comes back grouped by format, ready to drop into markup.
In the result
- A srcset per format, sorted ascending
- result.savings and result.warnings alongside
In your markup
- toPicture() returns picture sources and an img fallback
- Keys are content-addressed, so immutable caching is safe
page.tsxts result.formats.webp.srcset; // 'https://…-300w.webp 300w, https://…-600w.webp 600w, https://…-1200w.webp 1200w' import { toPicture } from '@lens-image/core'; const pic = toPicture(result, { alt: 'Cabin at dusk', sizes: '(max-width: 768px) 100vw, 50vw', });
One interface,
one required method.
Processing and storage are separate concerns, so they are separate packages. Pick a backend, or write one. The call above it never changes.
Amazon S3, and everything that speaks its API.Cloudflare R2, MinIO, DigitalOcean Spaces and Backblaze B2 all work through the same adapter, set an endpoint and go. The AWS SDK stays a peer dependency, so you keep your own version.
The filesystem, done carefully.Atomic writes, so a crash mid-upload leaves the previous file intact rather than a truncated one. Any key that resolves outside the root is refused outright. Zero dependencies.
Cloudinary, without the SDK.Signed uploads are an HTTP POST with a SHA-1 signature, which node:crypto and the built-in fetch cover in a couple of hundred lines. Install cost: zero packages.
Or forty lines of your own.The contract is a name and an upload function. Implement exists and remove to unlock storage-aware caching and deletion. Retries, concurrency limiting and error wrapping are handled for you.
Or no storage at all.Leave the adapter out and Lens runs in process-only mode: no URLs are invented, and the encoded bytes come back on each variant for you to do as you like with.
A missing codec
is not a 500.
Prebuilt sharp binaries do not all ship AV1. Where AVIF is unavailable it is skipped and recorded as a warning, and the WebP and JPEG your page needs still resolve.
const result = await optimizer.optimize({
source,
formats: ['avif', 'webp', 'jpg'],
});
// AVIF unavailable on this platform? You still get WebP and JPEG.
result.warnings;
// [{ code: 'format_unsupported', format: 'avif', message: '…' }]result.formats
- avifskipped
- webpready
- jpgready
result.warnings
format_unsupported · avif
Only a total failure throws, and then it is a LensError with a stable code and a suggested HTTP status.
Good questions,
answered by humans.
The straight versions, checked against the source.
Does the core really have zero dependencies?
Yes, in the literal sense: @lens-image/core has no entries in dependencies, so installing it adds one package to your lockfile and downloads no native binaries. A CI check enforces it.
The qualifier is that pixel work needs sharp, which Lens declares as an optional peer dependency and you install yourself. The adapters depend on the core and nothing else; the S3 adapter takes the AWS SDK as a peer.
Do I need sharp?
To resize and encode, yes, by default: it is imported lazily the first time you encode something. If you only validate, hash and store originals, the built-in passthrough engine needs no codec at all.
The codec is an interface too (ImageEngine, three methods), so you can swap sharp out. If it is missing when needed, you get a clear ENGINE_UNAVAILABLE error naming the install command.
Which storage backends are supported?
Three packages ship today: S3 (plus R2, MinIO, DigitalOcean Spaces and Backblaze B2 through an endpoint), the local filesystem, and Cloudinary.
Anything else is an adapter with one required method, upload(). You can also leave the adapter out entirely and get the encoded bytes back to handle yourself. Storage adapters
Does it run in the browser or at the edge?
Part of it does. @lens-image/core/browser is a subset with no node: imports (format sniffing, validation, key templating) that bundles for a browser, a Worker or an edge runtime, so you can reject a bad file from its first 4 KB before the upload starts.
Encoding is a different matter: the default engine is sharp, a native module, so it runs where sharp does. The upload handler is a plain Request to Response function, so it is not tied to one framework.
What happens if AVIF is unavailable?
Prebuilt sharp binaries do not all ship AV1. When AVIF cannot be encoded, that format is skipped and recorded in result.warnings as format_unsupported; the other formats you asked for still come back.
If every requested format fails, Lens retries in fallbackFormat (JPEG by default), and only a total failure throws. Treat AVIF as best-effort.
Is it safe with untrusted uploads?
The defaults are built for it: format is sniffed from the bytes rather than trusted from the extension, a 100-megapixel guard (checked from the header, before any decoder runs) stops decompression bombs, and uploads are capped at 25 MB. Metadata such as EXIF is stripped, remote URL fetching is off, and the filesystem adapter refuses keys that escape its root.
Authentication is still yours: the upload handler takes an authorize hook for it. And the library is pre-1.0, so pin an exact version.



