Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
Uploads

Image variants

Signed URLs an image service renders on demand.

An uploaded image is served as it was uploaded. A variant is a resized, cropped, or re-encoded copy. An image service outside ohne renders it on demand, from a URL that ohne signs. ohne stores no variants and runs no image code: it builds the URL, and the service does the work and caches the result.

ts
import { imageURL } from 'ohnejs/uploads';

imageURL(upload, { width: 800, format: 'webp' });
// -> 'https://img.example.com/2Obrt.../w_800,f_webp/photos/2024/sunset.jpg'

Connecting a service

Set uploads.images.url in the uploads config to the service origin, and UPLOADS_SECRET in the environment to a secret you share with it:

ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base', 'ohnejs/uploads'],
  uploads: {
    images: { url: 'https://img.example.com' },
  },
});
Terminal
UPLOADS_SECRET=a-long-random-value

Give the service the same value as its IMAGES_SECRET.

Without a URL, imageURL returns the original file's URL, so a page renders the same either way.

Without a secret, the URL carries unsigned where the signature would go:

  • A service started with --unsigned renders it.
  • A signing service refuses it with 403.
  • ohne warns at boot.

That is for your own machine, never for a service others can reach.

The reference implementation, @ohnejs/images, runs on Node and sharp; any service that follows the protocol works.

Named variants

Most pages need the same few sizes everywhere. Name them once under uploads.images.variants. Each one is a transforms object:

ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base', 'ohnejs/uploads'],
  uploads: {
    images: {
      url: 'https://img.example.com',
      variants: {
        card: { width: 640, height: 360, format: 'webp' },
        cardWide: { width: 1280, height: 720, format: 'webp' },
      },
    },
  },
});
  • A name is a camelCase identifier.
  • The layer ships thumbnail as { width: 320, height: 320, fit: 'inside', format: 'webp' }, for the dashboard grid.
  • Names from every layer are combined. Defining a name again replaces the whole preset, so thumbnail: { width: 200 } does not keep the shipped height.
  • A name that is not an identifier, or a preset with no transforms, fails at boot.

Every read of an image carries variants, one signed URL per name, when a service is configured:

ts
const upload = await query('Uploads').where('UUID', uuid).findFirst();

upload.variants.thumbnail;
// -> 'https://img.example.com/.../w_320,h_320,fit_inside,f_webp/photos/sunset.jpg'

upload.variants.card;
// -> 'https://img.example.com/.../w_640,h_360,f_webp/photos/sunset.jpg'

Without a service, for a file that is not an image, or for a private image while UPLOADS_SECRET is unset, variants is absent. A private image's variants expire with its url. The dashboard's details popup lists them under Variants.

In server code, pass the name instead of a transforms object:

ts
import { imageURL } from 'ohnejs/uploads';

imageURL(upload, 'thumbnail');
// -> 'https://img.example.com/.../w_320,h_320,fit_inside,f_webp/photos/sunset.jpg'

imageURL(upload);
// -> '/uploads/photos/sunset.jpg'

After ohne prepare or ohne dev, ImageVariantName is the union of every configured name, so a typo in imageURL or imageSrcSet fails to type-check. At runtime an unknown name throws.

Cropping

fit decides how a variant fills its box:

  • cover, the default, fills the box and crops.
  • contain letterboxes: it fits the whole image and pads the rest of the box.
  • inside shrinks to fit.

With cover, position or focalPoint decides which part of the image the crop keeps. When the fit is cover and the transforms set neither, imageURL adds the upload's own focal point, so a crop keeps the subject an editor marked in the dashboard.

contain and inside never crop, so their URLs carry no focal point and stay the same when an editor moves it.

Responsive images

imageSrcSet builds a srcset from a list of entries. Each entry is a variant name or a transforms object, and becomes a signed URL with the entry's width as its w descriptor:

ts
import { imageSrcSet, imageURL } from 'ohnejs/uploads';

const src = imageURL(upload, 'card');
const srcset = imageSrcSet(upload, ['card', 'cardWide']);
// -> 'https://img.example.com/.../w_640,h_360,f_webp/photos/sunset.jpg 640w, https://img.example.com/.../w_1280,h_720,f_webp/photos/sunset.jpg 1280w'

Use it with sizes on the <img>, and the browser picks the width it needs. An entry without a width throws, because there is no descriptor to write. Transforms objects work the same way:

ts
imageSrcSet(upload, [
  { width: 400, format: 'webp' },
  { width: 800, format: 'webp' },
]);
// -> 'https://img.example.com/.../w_400,f_webp/photos/sunset.jpg 400w, https://img.example.com/.../w_800,f_webp/photos/sunset.jpg 800w'

format: 'auto' lets the service pick the format from the browser's Accept header. Use it only behind a CDN that normalizes Accept; without one, every browser's Accept string is a separate cache entry.

Rotating the secret

UPLOADS_SECRET may list several secrets, and a service accepts a URL signed by any of them. To rotate:

  1. Add the new secret on the service, after the old one. The service signs its fetch of a private original with its first secret, which ohne must already know.
  2. On ohne, put it first in the list.
  3. Once every page has re-rendered, remove the old one from the service, then from ohne.

Allowing only your variants

A service can refuse everything except your variants. imageVariantTokens returns the canonical token string of every name:

ts
import { imageVariantTokens } from 'ohnejs/uploads';

imageVariantTokens();
// -> { thumbnail: 'w_320,h_320,fit_inside,f_webp', card: 'w_640,h_360,f_webp' }

Give them to the service, and a signed URL outside the list is a 403. The reference service takes them as --variants.