Lens

Start here

Getting started

Install, optimize one image, then wire up a real upload endpoint. About ten minutes.


Install

The core plus a codec. sharp is a separate install because it is a native module, see why.

npm install @lens-image/core sharp

Then pick where the output goes:

npm install @lens-image/adapter-local

Optimize an image

One call produces every format at every size and uploads each one. Formats are independent: if one fails, the rest still resolve.

optimize.tsts
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.

app/api/upload/route.tsts
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.

components/Uploader.tsxtsx
'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.