diff --git a/.changeset/autorender-cdn-provider.md b/.changeset/autorender-cdn-provider.md
new file mode 100644
index 000000000..8734d7c0f
--- /dev/null
+++ b/.changeset/autorender-cdn-provider.md
@@ -0,0 +1,5 @@
+---
+'@responsive-image/cdn': minor
+---
+
+Add Autorender image CDN provider
diff --git a/apps/docs/.vitepress/config.ts b/apps/docs/.vitepress/config.ts
index d730c925b..94f74d53a 100644
--- a/apps/docs/.vitepress/config.ts
+++ b/apps/docs/.vitepress/config.ts
@@ -114,6 +114,7 @@ export default defineConfig({
link: '/',
base: '/cdn',
items: [
+ { text: 'Autorender', link: '/autorender' },
{ text: 'Cloudinary', link: '/cloudinary' },
{ text: 'Fastly', link: '/fastly' },
{ text: 'Imgix', link: '/imgix' },
diff --git a/apps/docs/src/cdn/autorender.md b/apps/docs/src/cdn/autorender.md
new file mode 100644
index 000000000..5a5a7959a
--- /dev/null
+++ b/apps/docs/src/cdn/autorender.md
@@ -0,0 +1,170 @@
+---
+outline: [2, 3]
+---
+
+# Autorender
+
+The image processing capabilities of the [Autorender](https://autorender.io) image CDN are supported by a helper function provided to you by this library.
+
+## Setup
+
+Make sure you have the `@responsive-image/cdn` package installed:
+
+::: code-group
+
+```bash [npm]
+npm install @responsive-image/cdn
+```
+
+```bash [yarn]
+yarn add @responsive-image/cdn
+```
+
+```bash [pnpm]
+pnpm add @responsive-image/cdn
+```
+
+:::
+
+You need to specify your Autorender `domain` and `workspace` in your configuration, which you can set up in your application (e.g. `app.js`). The workspace ID is the public routing identifier in your delivery URLs and is not a secret:
+
+```js
+import { setConfig } from '@responsive-image/core';
+
+setConfig('cdn', {
+ autorender: {
+ domain: 'assets.autorender.io',
+ workspace: 'LOKVTtKVGb',
+ },
+});
+```
+
+## Usage
+
+> [!IMPORTANT]
+> Please make sure you have read the section on [remote images](../usage/remote-images.md) first.
+
+Use the autorender provider function passing the source path of the image inside your workspace, and pass the return value to the [image component](../usage/component.md):
+
+::: code-group
+
+```gjs [Ember .gjs]
+import { ResponsiveImage } from '@responsive-image/ember';
+import { autorender } from '@responsive-image/cdn';
+
+
+
+
+```
+
+```hbs [Ember .hbs]
+
+```
+
+```ts [Lit]
+import { LitElement, html } from 'lit';
+import { customElement } from 'lit/decorators.js';
+import { autorender } from '@responsive-image/cdn';
+import '@responsive-image/wc';
+
+@customElement('my-app')
+export class MyApp extends LitElement {
+ render() {
+ return html``;
+ }
+}
+```
+
+```tsx [React]
+import { ResponsiveImage } from '@responsive-image/react';
+import { autorender } from '@responsive-image/cdn';
+
+export default function MyApp() {
+ return ;
+}
+```
+
+```tsx [Solid]
+import { ResponsiveImage } from '@responsive-image/solid';
+import { autorender } from '@responsive-image/cdn';
+
+export default function MyApp() {
+ return ;
+}
+```
+
+```svelte [Svelte]
+
+
+
+```
+
+```vue [Vue]
+
+
+
+
+
+```
+
+:::
+
+### Aspect Ratio
+
+For the image component to be able to render `width` and `height` attributes to prevent layout shifts after loading has completed, it needs to know the aspect ratio of the source image. Unlike [local images](../usage/local-images.md) it cannot know this upfront for remote images, that's why it is recommended to supply the `aspectRatio` parameter if possible:
+
+```ts [Lit]
+autorender('products/chair.jpg', {
+ aspectRatio: 1.5,
+});
+```
+
+### Quality
+
+Use the `quality` parameter to pass a custom quality setting (`1`–`100`) instead of Autorender's per-format default:
+
+```ts [Lit]
+autorender('products/chair.jpg', {
+ quality: 50,
+});
+```
+
+### Image formats
+
+By default the component lets Autorender select the format from the request `Accept` header.
+
+If you want a `picture` tag with one or more specific formats as `source` tags you can specify them using the `formats` argument. Autorender supports `avif`, `webp`, `jpeg`, `png`, `gif`, and `tiff`:
+
+```ts [Lit]
+autorender('products/chair.jpg', {
+ formats: ['avif', 'webp'],
+});
+```
+
+### Remote images
+
+Besides source paths inside your workspace, you can pass a full `http(s)` URL. Autorender fetches the remote image, then optimizes and delivers it through the CDN — useful when your originals live elsewhere and aren't uploaded to the workspace:
+
+```ts [Lit]
+autorender('https://images.example.com/products/chair.jpg', {
+ formats: ['avif', 'webp'],
+});
+```
+
+### Custom transforms
+
+Besides the resizing and format tokens the library adds implicitly, you can append any additional [Autorender transform tokens](https://autorender.io/docs/transformations/introduction) verbatim by passing a `transforms` array:
+
+```ts [Lit]
+autorender('products/chair.jpg', {
+ transforms: ['e_sharpen', 'r_16'],
+});
+```
diff --git a/apps/docs/src/cdn/index.md b/apps/docs/src/cdn/index.md
index 2daa8a2e8..de026062e 100644
--- a/apps/docs/src/cdn/index.md
+++ b/apps/docs/src/cdn/index.md
@@ -6,6 +6,7 @@ With image CDNs the image processing is offloaded to the Cloud. This allows for
The following image CDNs are supported by the `@responsive-image/cdn` package out of the box:
+- [Autorender](./autorender.md)
- [Cloudinary](./cloudinary.md)
- [Fastly](./fastly.md)
- [Imgix](./imgix.md)
diff --git a/packages/cdn/src/autorender.ts b/packages/cdn/src/autorender.ts
new file mode 100644
index 000000000..a0db48d53
--- /dev/null
+++ b/packages/cdn/src/autorender.ts
@@ -0,0 +1,101 @@
+import { assert, getConfig } from '@responsive-image/core';
+
+import type { Config, CoreOptions } from './types';
+import type { ImageData, ImageUrlForType } from '@responsive-image/core';
+
+export interface AutorenderConfig {
+ /**
+ * Delivery domain that serves your transformed images,
+ * e.g. `assets.autorender.io`.
+ */
+ domain: string;
+ /**
+ * Public workspace ID that routes the request, e.g. `LOKVTtKVGb`.
+ * It is part of the delivery URL and is not a secret.
+ */
+ workspace: string;
+}
+
+export interface AutorenderOptions extends CoreOptions {
+ /**
+ * Extra Autorender transform tokens applied verbatim, e.g.
+ * `['e_sharpen', 'r_16']`. See the transformation reference for the
+ * full token vocabulary.
+ */
+ transforms?: string[];
+}
+
+const ABSOLUTE_URL_RE = /^https?:\/\//i;
+
+function normalizeSrc(src: string): string {
+ return src[0] === '/' ? src.slice(1) : src;
+}
+
+/**
+ * Escape the structural characters of a remote fetch URL so it survives as a
+ * single transform token: `%` (round-trip safety), `,` (token delimiter),
+ * `?` and `#` (which would otherwise terminate the path). Slashes and colons
+ * stay literal; the delivery backend percent-decodes path segments.
+ */
+function escapeFetchPayload(url: string): string {
+ return url
+ .replace(/%/g, '%25')
+ .replace(/,/g, '%2C')
+ .replace(/\?/g, '%3F')
+ .replace(/#/g, '%23');
+}
+
+export function autorender(
+ image: string,
+ options: AutorenderOptions = {},
+): ImageData {
+ const config = getConfig('cdn')?.autorender;
+ const domain = config?.domain;
+ const workspace = config?.workspace;
+ assert(
+ 'domain must be set for the autorender provider!',
+ typeof domain === 'string',
+ );
+ assert(
+ 'workspace must be set for the autorender provider!',
+ typeof workspace === 'string',
+ );
+
+ const isRemote = ABSOLUTE_URL_RE.test(image.trim());
+ const src = isRemote ? image.trim() : normalizeSrc(image);
+
+ const imageData: ImageData = {
+ imageTypes: options.formats ?? 'auto',
+ imageUrlFor(width: number, type: ImageUrlForType = 'jpeg'): string {
+ const tokens = [`w_${width}`];
+
+ // Autorender accepts `f_jpeg` directly, so the format name maps 1:1.
+ // `auto` emits `f_auto`, letting Autorender negotiate from the
+ // Accept header instead of pinning a single format.
+ tokens.push(`f_${type}`);
+
+ if (options.quality) {
+ tokens.push(`q_${options.quality}`);
+ }
+
+ if (options.transforms) {
+ tokens.push(...options.transforms);
+ }
+
+ // Remote sources become a `fetch_` transform token (not a path
+ // segment); the workspace-relative path otherwise trails the tokens.
+ if (isRemote) {
+ tokens.push(`fetch_${escapeFetchPayload(src)}`);
+ return `https://${domain}/${workspace}/${tokens.join(',')}`;
+ }
+
+ return `https://${domain}/${workspace}/${tokens.join(',')}/${src}`;
+ },
+ };
+
+ if (options.aspectRatio) {
+ imageData.aspectRatio = options.aspectRatio;
+ }
+
+ return imageData;
+}
diff --git a/packages/cdn/src/index.ts b/packages/cdn/src/index.ts
index 61e3a6e48..4134bc7fa 100644
--- a/packages/cdn/src/index.ts
+++ b/packages/cdn/src/index.ts
@@ -1,3 +1,4 @@
+export * from './autorender.ts';
export * from './cloudinary.ts';
export * from './fastly.ts';
export * from './imgix.ts';
diff --git a/packages/cdn/src/types.ts b/packages/cdn/src/types.ts
index 5c63c32c9..7e02a73f4 100644
--- a/packages/cdn/src/types.ts
+++ b/packages/cdn/src/types.ts
@@ -1,3 +1,4 @@
+import type { AutorenderConfig } from './autorender';
import type { CloudinaryConfig } from './cloudinary';
import type { FastlyConfig } from './fastly';
import type { ImgixConfig } from './imgix';
@@ -5,6 +6,7 @@ import type { NetlifyConfig } from './netlify';
import type { ImageTypeAuto, ImageType } from '@responsive-image/core';
export interface Config {
+ autorender?: AutorenderConfig;
imgix?: ImgixConfig;
fastly?: FastlyConfig;
cloudinary?: CloudinaryConfig;
diff --git a/packages/cdn/tests/autorender.test.ts b/packages/cdn/tests/autorender.test.ts
new file mode 100644
index 000000000..d68ed66f3
--- /dev/null
+++ b/packages/cdn/tests/autorender.test.ts
@@ -0,0 +1,108 @@
+import { setConfig } from '@responsive-image/core';
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { autorender } from '../src';
+
+import type { Config } from '../src';
+
+describe('autorender', function () {
+ beforeAll(() => {
+ setConfig('cdn', {
+ autorender: { domain: 'assets.autorender.io', workspace: 'LOKVTtKVGb' },
+ });
+ });
+
+ test('it lets the CDN choose image type by default', function () {
+ const result = autorender('products/chair.jpg');
+
+ expect(result?.imageTypes).toEqual('auto');
+ });
+
+ test('it supports custom image types', function () {
+ const result = autorender('products/chair.jpg', {
+ formats: ['jpeg', 'webp'],
+ });
+
+ expect(result?.imageTypes).toEqual(['jpeg', 'webp']);
+ });
+
+ test('it returns correct image URLs', function () {
+ const result = autorender('products/chair.jpg');
+
+ expect(result.imageUrlFor(100, 'jpeg')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg/products/chair.jpg',
+ );
+
+ expect(result.imageUrlFor(1000, 'webp')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_1000,f_webp/products/chair.jpg',
+ );
+ });
+
+ test('it normalizes a leading slash in the source path', function () {
+ const result = autorender('/products/chair.jpg');
+
+ expect(result.imageUrlFor(100, 'jpeg')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg/products/chair.jpg',
+ );
+ });
+
+ test('it emits f_auto for the auto type', function () {
+ const result = autorender('products/chair.jpg', { formats: 'auto' });
+
+ expect(result.imageUrlFor(100, 'auto')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_auto/products/chair.jpg',
+ );
+ });
+
+ test('it supports custom quality setting', function () {
+ const result = autorender('products/chair.jpg', { quality: 50 });
+
+ expect(result.imageUrlFor(100, 'jpeg')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg,q_50/products/chair.jpg',
+ );
+ });
+
+ test('it appends custom transform tokens', function () {
+ const result = autorender('products/chair.jpg', {
+ transforms: ['e_sharpen', 'r_16'],
+ });
+
+ expect(result.imageUrlFor(100, 'jpeg')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg,e_sharpen,r_16/products/chair.jpg',
+ );
+ });
+
+ test('it supports custom aspectRatio', function () {
+ const result = autorender('products/chair.jpg', { aspectRatio: 2 });
+
+ expect(result.aspectRatio).toBe(2);
+ });
+
+ test('it fetches a remote URL as a fetch_ transform token', function () {
+ const result = autorender('https://images.example.com/chair.jpg');
+
+ expect(result.imageUrlFor(100, 'webp')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_webp,fetch_https://images.example.com/chair.jpg',
+ );
+ });
+
+ test('it escapes structural characters in a remote fetch URL', function () {
+ const result = autorender(
+ 'https://images.example.com/chair.jpg?v=2&size=xl',
+ );
+
+ expect(result.imageUrlFor(100, 'webp')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_webp,fetch_https://images.example.com/chair.jpg%3Fv=2&size=xl',
+ );
+ });
+
+ test('it appends custom transforms before the fetch token', function () {
+ const result = autorender('https://images.example.com/chair.jpg', {
+ transforms: ['e_sharpen'],
+ });
+
+ expect(result.imageUrlFor(100, 'webp')).toBe(
+ 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_webp,e_sharpen,fetch_https://images.example.com/chair.jpg',
+ );
+ });
+});