Install
The core plus a codec. sharp is a separate install because it is a native module, see why.
npm install @lens-image/core sharpThen pick where the output goes:
npm install @lens-image/adapter-localOptimize an image
One call produces every format at every size and uploads each one. Formats are independent: if one fails, the rest still resolve.
import { ImageOptimizer } from '@lens-image/core';
import { LocalAdapter } from '@lens-image/adapter-local';
const optimizer = new ImageOptimizer({
adapter: new LocalAdapter({
root: './public/uploads', // where files land
baseUrl: '/uploads', // how the browser reaches them
}),
quality: 80,
});
const result = await optimizer.optimize({
source: './photo.jpg',
formats: ['webp', 'jpg'],
sizes: [{ width: 1200 }, { width: 600 }, { width: 300 }],
});That writes six files (three widths in WebP and three in JPEG) under ./public/uploads.
Read the result
Variants are grouped by format, keyed by size label, with a srcset already assembled.
result.formats.webp.urls['600w']; // '/uploads/a1b2c3d4e5/photo-600w.webp'
result.formats.webp.srcset; // the whole ladder, sorted ascending
result.formats.webp.largest; // widest variant, for a plain src
result.savings; // 0.82, i.e. 82% smaller than the original
result.warnings; // anything that degraded
result.durationMs;Render it
Source order follows your formats array, so ['avif', 'webp', 'jpg'] produces exactly the progressive-enhancement ladder you would write by hand.
import { toPicture } from '@lens-image/core';
const pic = toPicture(result, {
alt: 'Cabin at dusk',
sizes: '(max-width: 768px) 100vw, 50vw',
});
<picture>
{pic.sources.map((s) => <source key={s.type} {...s} />)}
<img src={pic.src} width={pic.width} height={pic.height} alt={pic.alt} loading="lazy" />
</picture>Move to real storage
Change the adapter. Nothing above it moves. The call, the options and the result shape are identical.
import { S3Adapter } from '@lens-image/adapter-s3';
const optimizer = new ImageOptimizer({
adapter: new S3Adapter({
bucket: 'my-images',
region: 'us-east-1',
publicUrl: 'https://cdn.example.com', // your CloudFront distribution
}),
});Accept uploads
createUploadHandler is a Request → Response function built only on web standards, so it drops into Next.js, Remix, Hono, Bun or Deno unmodified.
import { ImageOptimizer, createUploadHandler } from '@lens-image/core';
import { S3Adapter } from '@lens-image/adapter-s3';
const optimizer = new ImageOptimizer({
adapter: new S3Adapter({ bucket: 'uploads', region: 'us-east-1' }),
});
export const POST = createUploadHandler({
optimizer,
maxFiles: 5,
async authorize(request) {
const session = await auth(request);
return session ? undefined : Response.json({ error: 'Unauthorized' }, { status: 401 });
},
options: {
formats: ['webp', 'jpg'],
sizes: [{ width: 1200 }, { width: 600 }],
thumbnail: true,
},
});Errors map to their status automatically: a too-large file is a 422 with a readable message, not a 500.
Wire up the client
@lens-image/react is state only, no components and no CSS, so it composes with whatever you are already styling with.
'use client';
import { useImageUpload } from '@lens-image/react';
export function Uploader() {
const { upload, items, progress, isUploading, error } = useImageUpload({
endpoint: '/api/upload',
maxBytes: 10 * 1024 * 1024,
accept: ['image/*'],
});
return (
<>
<input type="file" multiple disabled={isUploading}
onChange={(e) => upload(e.target.files)} />
{isUploading && <progress value={progress} max={100} />}
{error && <p role="alert">{error.message}</p>}
{items.map((item) => (
<img key={item.id} src={item.result?.thumbnail?.url ?? item.previewUrl} alt="" />
))}
</>
);
}Progress comes from XMLHttpRequest.upload.onprogress and is weighted by file size, so a 40 KB icon finishing does not push a bar tracking a 12 MB photo to 50%.
Without storage
Omit the adapter and Lens runs in process-only mode: no URLs are invented, and the encoded bytes come back on each variant.
const optimizer = new ImageOptimizer(); // no adapter
const { variants } = await optimizer.optimize({ source: bytes, formats: ['webp'] });
await writeFile('out.webp', variants[0].data);Next
The pipeline page explains how formats, quality, validation and caching interact, worth reading before you tune anything. The adapters page covers writing your own.