diff --git a/docs/content/3.providers/flyimg.md b/docs/content/3.providers/flyimg.md
new file mode 100644
index 000000000..d0432e6b5
--- /dev/null
+++ b/docs/content/3.providers/flyimg.md
@@ -0,0 +1,94 @@
+````mdc
+---
+title: Flyimg
+description: Nuxt Image has first class integration with Flyimg.
+links:
+ - label: Source
+ icon: i-simple-icons-github
+ to: https://github.com/nuxt/image/blob/main/src/runtime/providers/flyimg.ts
+ size: xs
+---
+
+Integration between [Flyimg](https://flyimg.io) and the image module.
+
+[Flyimg](https://github.com/flyimg/flyimg) is a self-hosted, open-source image processing server built on ImageMagick. It accepts images from a source URL and applies transformations on the fly, caching results for subsequent requests. A managed SaaS version is also available at [flyimg.io](https://flyimg.io).
+
+## Setup
+
+### Self‑hosted
+
+`baseURL` points to your **Flyimg server**. If you use relative image paths (e.g. ``), also set `sourceURL` to your **website's public URL** so that Flyimg can fetch the source image — it requires an absolute URL. Absolute `src` values (e.g. from a CDN) are passed through as-is and `sourceURL` is ignored for those.
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ image: {
+ flyimg: {
+ // URL of your Flyimg server
+ baseURL: 'https://flyimg.example.com',
+ // Public URL of your website — only needed for relative image paths
+ sourceURL: 'https://www.example.com',
+ }
+ }
+})
+```
+
+::callout{type="warning"}
+**nginx users:** nginx's default `merge_slashes on` setting collapses consecutive slashes in URLs, which corrupts the embedded `https://` in Flyimg request paths (e.g. `https://flyimg.example.com/upload/-/https://www.example.com/photo.jpg`). Add `merge_slashes off;` to the `server` block of your nginx config when reverse-proxying requests to Flyimg.
+::
+
+### Flyimg SaaS
+
+After [creating an instance](https://flyimg.io/documentation) you receive a unique subdomain (e.g. `img-abc123.flyimg.io`):
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ image: {
+ flyimg: {
+ // Unique subdomain provided by Flyimg SaaS
+ baseURL: 'https://img-abc123.flyimg.io',
+ // Public URL of your website — only needed for relative image paths
+ sourceURL: 'https://www.example.com',
+ }
+ }
+})
+```
+
+## Options
+
+| Option | Default | Description |
+|---|---|---|
+| `baseURL` | — | **Required.** URL of your Flyimg server or SaaS instance. |
+| `sourceURL` | — | Public base URL of your website. Only used for relative image paths — prefixed to make them absolute before passing to Flyimg. Absolute `src` values are unaffected. |
+| `processType` | `upload` | Flyimg process type: `upload` (serve the image) or `path` (return the path as text). |
+
+## Modifiers
+
+In addition to the standard `width`, `height`, `quality`, `format`, and `fit` modifiers, the Flyimg provider exposes the full range of [Flyimg URL options](https://docs.flyimg.io/url-options/).
+
+### `fit`
+
+| Nuxt Image value | Flyimg behaviour |
+|---|---|
+| `contain` / `inside` | Scale to fit within the target rectangle preserving aspect ratio (Flyimg default) |
+| `cover` | Crop to fill the rectangle (`c_1`) |
+| `fill` | Stretch to fill without preserving aspect ratio (`par_0`) |
+| `outside` | ⚠️ Unsupported — ignored (dev-time warning emitted) |
+
+### Additional Flyimg modifiers
+
+```vue
+
+```
+
+Refer to the [Flyimg URL options documentation](https://docs.flyimg.io/url-options/) for the full list of supported parameters.
+````
diff --git a/playground/app/providers.ts b/playground/app/providers.ts
index 6417d39fb..d3e595088 100644
--- a/playground/app/providers.ts
+++ b/playground/app/providers.ts
@@ -366,6 +366,31 @@ export const providers: Provider[] = [
},
],
},
+ // Flyimg
+ {
+ name: 'flyimg',
+ samples: [
+ {
+ src: 'https://picsum.photos/seed/nuxtimage/800/600',
+ width: 500,
+ height: 375,
+ quality: 80,
+ format: 'webp',
+ },
+ {
+ src: 'https://picsum.photos/seed/nuxtimage/800/600',
+ width: 300,
+ height: 200,
+ fit: 'cover',
+ quality: 75,
+ },
+ {
+ src: 'https://picsum.photos/seed/nuxtimage/800/600',
+ width: 200,
+ fit: 'contain',
+ },
+ ],
+ },
// ImageKit
{
name: 'imagekit',
diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts
index 9e9db4485..c83909a01 100644
--- a/playground/nuxt.config.ts
+++ b/playground/nuxt.config.ts
@@ -72,6 +72,10 @@ export default defineNuxtConfig({
filerobot: {
baseURL: 'https://fesrinkeb.filerobot.com/',
},
+ flyimg: {
+ baseURL: 'https://demo.flyimg.io',
+ sourceURL: 'https://picsum.photos',
+ },
github: {},
glide: {
baseURL: 'https://glide.herokuapp.com/1.0/',
diff --git a/src/provider.ts b/src/provider.ts
index 1ef7bfcff..817c721c6 100644
--- a/src/provider.ts
+++ b/src/provider.ts
@@ -25,6 +25,7 @@ export const BuiltInProviders = [
'directus',
'fastly',
'filerobot',
+ 'flyimg',
'github',
'glide',
'gumlet',
diff --git a/src/runtime/providers/flyimg.ts b/src/runtime/providers/flyimg.ts
new file mode 100644
index 000000000..ecd143c7c
--- /dev/null
+++ b/src/runtime/providers/flyimg.ts
@@ -0,0 +1,229 @@
+// https://docs.flyimg.io/url-options/
+
+import { joinURL, hasProtocol } from 'ufo'
+import { createOperationsGenerator } from '../utils/index'
+import { defineProvider } from '../utils/provider'
+
+/**
+ * Flyimg URL format:
+ * https://flyimg.example.com/{processType}/{image_options}/{path_to_image}
+ *
+ * Example:
+ * https://demo.flyimg.io/upload/w_300,h_200,q_85/https://example.com/image.jpg
+ */
+const operationsGenerator = createOperationsGenerator({
+ keyMap: {
+ // Core dimensions
+ width: 'w',
+ height: 'h',
+ quality: 'q',
+ format: 'o',
+ rotate: 'r',
+
+ // Cropping
+ crop: 'c',
+ gravity: 'g',
+
+ // WebP
+ webpLossless: 'webpl',
+ webpMethod: 'webpm',
+
+ // JPEG XL
+ jxlEffort: 'jxlef',
+ jxlDecodingSpeed: 'jxlds',
+
+ // Cache
+ refresh: 'rf',
+ version: 'v',
+
+ // Text / Watermark
+ text: 't',
+ textColor: 'tc',
+ textSize: 'ts',
+ textBackground: 'tbg',
+
+ // Image Processing
+ background: 'bg',
+ strip: 'st',
+ autoOrient: 'ao',
+ resize: 'rz',
+ mozjpeg: 'moz',
+ unsharp: 'unsh',
+ sharpen: 'sh',
+ blur: 'blr',
+ filter: 'f',
+ scale: 'sc',
+ samplingFactor: 'sf',
+ preserveAspectRatio: 'par',
+ preserveNaturalSize: 'pns',
+
+ // Advanced
+ faceCrop: 'fc',
+ faceCropPosition: 'fcp',
+ faceBlur: 'fb',
+ smartCrop: 'smc',
+ colorspace: 'clsp',
+ monochrome: 'mnchr',
+
+ // PDF
+ pdfPage: 'pdfp',
+ density: 'dnst',
+
+ // Video
+ videoTime: 'tm',
+
+ // Extract
+ extract: 'e',
+ extractTopX: 'p1x',
+ extractTopY: 'p1y',
+ extractBottomX: 'p2x',
+ extractBottomY: 'p2y',
+
+ // Other
+ extent: 'ett',
+ gifFrame: 'gf',
+ },
+ valueMap: {
+ // Booleans become 0 / 1
+ crop: Number,
+ webpLossless: Number,
+ refresh: Number,
+ autoOrient: Number,
+ resize: Number,
+ scale: Number,
+ faceCrop: Number,
+ faceBlur: Number,
+ smartCrop: Number,
+ monochrome: Number,
+ extract: Number,
+ // Inverted-defaults (strip/mozjpeg/par/pns default ON on the server;
+ // we only emit them when explicitly set to false — see getImage pre-processing)
+ strip: Number,
+ mozjpeg: Number,
+ preserveAspectRatio: Number,
+ preserveNaturalSize: Number,
+ // Encode colours so # does not break the URL path segment
+ background: (value: string) => value.startsWith('#') ? value.replace('#', '%23') : value,
+ textColor: (value: string) => value.startsWith('#') ? value.replace('#', '%23') : value,
+ textBackground: (value: string) => value.startsWith('#') ? value.replace('#', '%23') : value,
+ // Encode text watermarks
+ text: (value: string) => encodeURIComponent(value),
+ },
+ joinWith: ',',
+ formatter: (key, value) => `${key}_${value}`,
+})
+
+interface FlyimgOptions {
+ /**
+ * Base URL of the Flyimg server.
+ *
+ * For the official Flyimg SaaS each instance gets a unique subdomain:
+ * `https://img-abc123.flyimg.io`
+ *
+ * For self-hosted instances use the URL of your deployment,
+ * e.g. `https://images.example.com`.
+ */
+ baseURL: string
+
+ /**
+ * Public base URL of your website.
+ *
+ * Only applied to **relative** image paths (e.g. `/images/photo.jpg`) —
+ * the value is prepended to produce an absolute URL that Flyimg can fetch.
+ * Absolute `src` values (e.g. from a CDN) are passed through unchanged and
+ * this option has no effect for those.
+ *
+ * Example: `https://www.example.com`
+ */
+ sourceURL?: string
+
+ /**
+ * Flyimg process type.
+ *
+ * - `upload` (default): fetch, transform, cache and serve the image.
+ * - `path`: same as upload but returns the path to the cached image as a
+ * plain-text response body instead of serving the image directly.
+ *
+ * @default 'upload'
+ */
+ processType?: 'upload' | 'path'
+}
+
+export default defineProvider({
+ getImage: (src, options) => {
+ const {
+ modifiers: rawModifiers = {},
+ baseURL,
+ sourceURL,
+ processType = 'upload',
+ } = options
+
+ if (import.meta.dev && !baseURL) {
+ console.warn('[nuxt] [image] [flyimg] `baseURL` is required. Set it in your nuxt.config under `image.flyimg.baseURL`.')
+ }
+
+ // --- fit → Flyimg flags ------------------------------------------------
+ const {
+ fit,
+ strip,
+ mozjpeg,
+ preserveAspectRatio,
+ preserveNaturalSize,
+ ...rest
+ } = rawModifiers as Record
+
+ const modifiers: Partial> = { ...rest } as Partial>
+
+ switch (fit) {
+ case 'cover':
+ // Crop to fill the target rectangle (Flyimg: c_1)
+ if (!modifiers.crop) modifiers.crop = true
+ break
+ case 'fill':
+ // Stretch to fill — disable aspect-ratio preservation (Flyimg: par_0)
+ if (preserveAspectRatio !== false) modifiers.preserveAspectRatio = false
+ break
+ case 'contain':
+ case 'inside':
+ // Flyimg default behaviour when width + height are given — no extra flags needed
+ break
+ case 'outside':
+ if (import.meta.dev) {
+ console.warn('[nuxt] [image] [flyimg] fit="outside" is not supported by Flyimg and will be ignored.')
+ }
+ break
+ }
+
+ // --- Inverted-defaults --------------------------------------------------
+ // strip / mozjpeg / preserveAspectRatio / preserveNaturalSize default to
+ // 1 (enabled) in Flyimg, so we only need to emit them when explicitly
+ // disabled. Treat boolean false, numeric 0, and string '0' as opt-out.
+ const isDisabled = (v: unknown) => v === false || v === 0 || v === '0'
+ if (strip != null && isDisabled(strip)) modifiers.strip = false
+ if (mozjpeg != null && isDisabled(mozjpeg)) modifiers.mozjpeg = false
+ if (preserveAspectRatio != null && isDisabled(preserveAspectRatio)) modifiers.preserveAspectRatio = false
+ if (preserveNaturalSize != null && isDisabled(preserveNaturalSize)) modifiers.preserveNaturalSize = false
+
+ // --- Resolve image URL -------------------------------------------------
+ // Flyimg needs an absolute source URL. If src is relative and sourceURL is
+ // configured, make it absolute.
+ if (import.meta.dev && !hasProtocol(src) && !sourceURL) {
+ console.warn('[nuxt] [image] [flyimg] `src` is a relative path but `sourceURL` is not configured. Flyimg requires an absolute source URL. Set `image.flyimg.sourceURL` in your nuxt.config.')
+ }
+ const imageUrl = !hasProtocol(src) && sourceURL
+ ? joinURL(sourceURL, src)
+ : src
+
+ // --- Build Flyimg URL --------------------------------------------------
+ const operations = operationsGenerator(modifiers as Partial>)
+ const imageOptions = operations || '-'
+
+ // Construct the path manually so that an absolute imageUrl is treated as a
+ // literal path segment rather than a new base by ufo's joinURL.
+ // Note: if reverse-proxied via nginx, set `merge_slashes off` to prevent
+ // nginx from collapsing the `https://` embedded in this path. See docs.
+ return {
+ url: joinURL(baseURL || '/', `${processType}/${imageOptions}/${imageUrl}`),
+ }
+ },
+})
diff --git a/test/e2e/__snapshots__/flyimg.json5 b/test/e2e/__snapshots__/flyimg.json5
new file mode 100644
index 000000000..02594e4e8
--- /dev/null
+++ b/test/e2e/__snapshots__/flyimg.json5
@@ -0,0 +1,12 @@
+{
+ "requests": [
+ "https://demo.flyimg.io/upload/w_200/https://picsum.photos/seed/nuxtimage/800/600",
+ "https://demo.flyimg.io/upload/w_300,h_200,q_75,c_1/https://picsum.photos/seed/nuxtimage/800/600",
+ "https://demo.flyimg.io/upload/w_500,h_375,o_webp,q_80/https://picsum.photos/seed/nuxtimage/800/600",
+ ],
+ "sources": [
+ "https://demo.flyimg.io/upload/w_500,h_375,o_webp,q_80/https://picsum.photos/seed/nuxtimage/800/600",
+ "https://demo.flyimg.io/upload/w_300,h_200,q_75,c_1/https://picsum.photos/seed/nuxtimage/800/600",
+ "https://demo.flyimg.io/upload/w_200/https://picsum.photos/seed/nuxtimage/800/600",
+ ],
+}
\ No newline at end of file
diff --git a/test/nuxt/providers.test.ts b/test/nuxt/providers.test.ts
index a867866a3..d4bd550b9 100644
--- a/test/nuxt/providers.test.ts
+++ b/test/nuxt/providers.test.ts
@@ -37,6 +37,7 @@ import wagtail from '../../dist/runtime/providers/wagtail'
import uploadcare from '../../dist/runtime/providers/uploadcare'
import sirv from '../../dist/runtime/providers/sirv'
import hygraph from '../../dist/runtime/providers/hygraph'
+import flyimg from '../../dist/runtime/providers/flyimg'
const emptyContext = {
options: {
@@ -718,6 +719,27 @@ describe('Providers', () => {
}
})
+ it('flyimg', () => {
+ const providerOptions = {
+ baseURL: 'https://demo.flyimg.io',
+ sourceURL: 'https://my-website.com',
+ }
+
+ for (const image of images) {
+ const [src, modifiers] = image.args
+ const generated = flyimg().getImage(src, { modifiers, ...providerOptions }, emptyContext)
+ expect(generated).toMatchObject(image.flyimg)
+ }
+
+ // fit: 'cover' → c_1 flag
+ expect(flyimg().getImage('/test.png', { modifiers: { width: 200, height: 200, fit: 'cover' }, ...providerOptions }, emptyContext))
+ .toMatchObject({ url: 'https://demo.flyimg.io/upload/w_200,h_200,c_1/https://my-website.com/test.png' })
+
+ // fit: 'fill' → par_0 flag
+ expect(flyimg().getImage('/test.png', { modifiers: { width: 200, height: 200, fit: 'fill' }, ...providerOptions }, emptyContext))
+ .toMatchObject({ url: 'https://demo.flyimg.io/upload/w_200,h_200,par_0/https://my-website.com/test.png' })
+ })
+
it('weserv', () => {
const providerOptions = {
baseURL: 'https://my-website.com/',
diff --git a/test/providers.ts b/test/providers.ts
index 75ec56e7c..545add86b 100644
--- a/test/providers.ts
+++ b/test/providers.ts
@@ -33,6 +33,7 @@ export const images = [
directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4' },
uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/' },
weserv: { url: 'https://wsrv.nl/?filename=test.png&we&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
+ flyimg: { url: 'https://demo.flyimg.io/upload/-/https://my-website.com/test.png' },
sirv: { url: 'https://demo.sirv.com/test.png' },
hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/auto_image/cltsrex89477t08unlckqx9ue' },
caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg' },
@@ -74,6 +75,7 @@ export const images = [
directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200' },
uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x/' },
weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
+ flyimg: { url: 'https://demo.flyimg.io/upload/w_200/https://my-website.com/test.png' },
sirv: { url: 'https://demo.sirv.com/test.png?w=200' },
hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200/auto_image/cltsrex89477t08unlckqx9ue' },
caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200' },
@@ -114,6 +116,7 @@ export const images = [
directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?height=200' },
uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/x200/' },
weserv: { url: 'https://wsrv.nl/?filename=test.png&we&h=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
+ flyimg: { url: 'https://demo.flyimg.io/upload/h_200/https://my-website.com/test.png' },
sirv: { url: 'https://demo.sirv.com/test.png?h=200' },
hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=height:200/auto_image/cltsrex89477t08unlckqx9ue' },
caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?h=200' },
@@ -154,6 +157,7 @@ export const images = [
directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200' },
uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/' },
weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
+ flyimg: { url: 'https://demo.flyimg.io/upload/w_200,h_200/https://my-website.com/test.png' },
sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200' },
hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200/auto_image/cltsrex89477t08unlckqx9ue' },
caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },
@@ -194,6 +198,7 @@ export const images = [
directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain' },
uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/-/stretch/off/' },
weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
+ flyimg: { url: 'https://demo.flyimg.io/upload/w_200,h_200/https://my-website.com/test.png' },
sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit' },
hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200,fit:max/auto_image/cltsrex89477t08unlckqx9ue' },
caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },
@@ -234,6 +239,7 @@ export const images = [
directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain&format=jpg' },
uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/format/jpeg/-/resize/200x200/-/stretch/off/' },
weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&output=jpg&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
+ flyimg: { url: 'https://demo.flyimg.io/upload/w_200,h_200,o_jpeg/https://my-website.com/test.png' },
sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit&format=jpg' },
hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200,fit:max/output=format:jpeg/cltsrex89477t08unlckqx9ue' },
caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },