Repository: nuxt/image
Pull request #1816 (2025-04-10 22:52:05)

refactor!: remove unused runtime utilities and simplify code
### 🔗 Linked issue

<!-- Please ensure there is an open issue and mention its number. For example, "resolves #123" -->

### ❓ Type of change

<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->

- [ ] 📖 Documentation (updates to the documentation or readme)
- [x] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description

this is a set of performance related changes that decreases bundle size + deduplicates code

cloudflare-workers-and-pages[bot]: ## Deploying nuxt-image with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>6ca1709</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://832af921.nuxt-image.pages.dev'>https://832af921.nuxt-image.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://refactor-cleanup.nuxt-image.pages.dev'>https://refactor-cleanup.nuxt-image.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/f1e84ebed8564d989bffd46d5a988296/pages/view/nuxt-image/832af921-f169-43ad-b11e-05e74d208722)

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/32a16066-6f36-4b7c-ae0e-1c4a9575674c)


```
npm i https://pkg.pr.new/@nuxt/image@1816
```


_commit: <a href="https://github.com/nuxt/image/runs/40360196874"><code>4947135</code></a>_

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/nuxt/image/pull/1816?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) Report
Attention: Patch coverage is `96.77419%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 63.15%. Comparing base [(`c4bba1b`)](https://app.codecov.io/gh/nuxt/image/commit/c4bba1b02a9dbcc3357a56513d5afaed722ac7f4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) to head [(`ba2ca98`)](https://app.codecov.io/gh/nuxt/image/commit/ba2ca9883617d3be5e2c8770e976609b9ecb2cfd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).

| [Files with missing lines](https://app.codecov.io/gh/nuxt/image/pull/1816?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) | Patch % | Lines |
|---|---|---|
| [src/runtime/utils/index.ts](https://app.codecov.io/gh/nuxt/image/pull/1816?src=pr&el=tree&filepath=src%2Fruntime%2Futils%2Findex.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3J1bnRpbWUvdXRpbHMvaW5kZXgudHM=) | 80.00% | [2 Missing :warning: ](https://app.codecov.io/gh/nuxt/image/pull/1816?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) |
| [src/runtime/components/NuxtPicture.vue](https://app.codecov.io/gh/nuxt/image/pull/1816?src=pr&el=tree&filepath=src%2Fruntime%2Fcomponents%2FNuxtPicture.vue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3J1bnRpbWUvY29tcG9uZW50cy9OdXh0UGljdHVyZS52dWU=) | 97.14% | [1 Missing :warning: ](https://app.codecov.io/gh/nuxt/image/pull/1816?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1816      +/-   ##
==========================================
+ Coverage   62.97%   63.15%   +0.17%     
==========================================
  Files          77       77              
  Lines        3795     3745      -50     
  Branches      433      429       -4     
==========================================
- Hits         2390     2365      -25     
+ Misses       1378     1353      -25     
  Partials       27       27              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nuxt/image/pull/1816?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/knip.json b/knip.json
index f54376a..e8898c1 100644
--- a/knip.json
+++ b/knip.json
@@ -5,11 +5,15 @@
       "entry": [
         "build.config.ts",
         "src/module.ts",
-        "src/runtime/**",
+        "src/runtime/index.ts",
+        "src/runtime/components/**",
+        "src/runtime/composables/**",
+        "src/runtime/plugins/**",
+        "src/runtime/providers/**",
+        "src/runtime/server/**",
         "scripts/*"
       ],
       "ignoreUnresolved": [
-        "#app/nuxt",
         "#internal/nuxt-image"
       ],
       "ignoreDependencies": [
@@ -38,8 +42,7 @@
         "ohash",
         "@vueuse/core",
         "@vueuse/nuxt",
-        "nuxt-og-image",
-        "perfect-debounce"
+        "nuxt-og-image"
       ]
     },
     "example": {
@@ -54,9 +57,6 @@
         "{app,error}.vue",
         "layers/**",
         "*.ts"
-      ],
-      "ignoreDependencies": [
-        "@nuxt/image"
       ]
     }
   }
diff --git a/src/ipx.ts b/src/ipx.ts
index fbeb721..eb41884 100644
--- a/src/ipx.ts
+++ b/src/ipx.ts
@@ -63,7 +63,7 @@ export const ipxSetup: IPXSetupT = setupOptions => (providerOptions, moduleOptio
   const ipxHandler = <NitroEventHandler>{
     route: `${ipxBaseURL}/**`,
     middleware: false,
-    handler: resolver.resolve('./runtime/ipx'),
+    handler: resolver.resolve('./runtime/server/routes/_ipx'),
   }
 
   if (!setupOptions?.isStatic) {
diff --git a/src/module.ts b/src/module.ts
index 87ee7f8..f16d5b6 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -195,7 +195,7 @@ ${BuiltInProviders.map(p => `            ${JSON.stringify(p)}: ReturnType<typeof
 
     if (options.inject) {
       // Add runtime plugin
-      addPlugin({ src: resolver.resolve('./runtime/plugin') })
+      addPlugin({ src: resolver.resolve('./runtime/plugins/image') })
     }
 
     // TODO: Transform asset urls that pass to `src` attribute on image components
diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue
index c290d56..a90268d 100644
--- a/src/runtime/components/NuxtImg.vue
+++ b/src/runtime/components/NuxtImg.vue
@@ -2,22 +2,14 @@
   <img
     v-if="!custom"
     ref="imgEl"
-    :class="placeholder && !placeholderLoaded ? placeholderClass : undefined"
-    v-bind="{
-      ...isServer ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {},
-      ...imgAttrs,
-      ...attrs,
-    }"
+    :class="placeholder ? placeholderClass : undefined"
+    v-bind="imgAttrs"
     :src="src"
   >
   <slot
     v-else
     v-bind="{
-      ...isServer ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {},
-      imgAttrs: {
-        ...imgAttrs,
-        ...attrs,
-      },
+      imgAttrs,
       isLoaded: placeholderLoaded,
       src,
     }"
@@ -30,11 +22,10 @@ import type { ImgHTMLAttributes } from 'vue'
 import type { ProviderDefaults, ConfiguredImageProviders } from '@nuxt/image'
 
 import { useImage } from '../composables'
-import { parseSize } from '../utils'
 import { prerenderStaticImages } from '../utils/prerender'
 import { markFeatureUsage } from '../utils/performance'
-import { useImageModifiers, useNormalisedAttrs, useProviderOptions } from './_base'
-import type { BaseImageProps } from './_base'
+import { useImageProps } from '../utils/props'
+import type { BaseImageProps } from '../utils/props'
 
 import { useHead, useNuxtApp, useRequestEvent } from '#imports'
 
@@ -44,7 +35,6 @@ export interface ImageProps<Provider extends keyof ConfiguredImageProviders> ext
   placeholderClass?: string
 }
 
-const attrs = useAttrs() as ImgHTMLAttributes
 const props = defineProps<ImageProps<Provider>>()
 
 const emit = defineEmits<{
@@ -52,51 +42,39 @@ const emit = defineEmits<{
   (event: 'error', payload: string | Event): unknown
 }>()
 
-defineSlots<{
-  default(props: {
-    imgAttrs: ImgHTMLAttributes
-    isLoaded: boolean
-    src?: string
-  }): any
-}>()
-
-const isServer = import.meta.server
-
-const modifiers = useImageModifiers(props)
+defineSlots<{ default(props: DefaultSlotProps): any }>()
 
 const $img = useImage()
-const providerOptions = useProviderOptions(props)
+const { providerOptions, normalizedAttrs, imageModifiers } = useImageProps(props)
 
 const sizes = computed(() => $img.getSizes(props.src!, {
   ...providerOptions.value,
   sizes: props.sizes,
   densities: props.densities,
-  modifiers: {
-    ...modifiers.value,
-    width: parseSize(props.width),
-    height: parseSize(props.height),
-  },
+  modifiers: imageModifiers.value,
 }))
 
 const placeholderLoaded = ref(false)
-const baseAttrs = useNormalisedAttrs(props)
 
+const attrs = useAttrs() as ImgHTMLAttributes
 const imgAttrs = computed(() => ({
-  ...baseAttrs.value,
+  ...normalizedAttrs.value,
   'data-nuxt-img': '',
   ...(!props.placeholder || placeholderLoaded.value)
     ? { sizes: sizes.value.sizes, srcset: sizes.value.srcset }
     : {},
+  ...import.meta.server ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {},
+  ...attrs,
 }))
 
 const placeholder = computed(() => {
-  let placeholder = props.placeholder
-
-  if (placeholder === '') {
-    placeholder = true
+  if (placeholderLoaded.value) {
+    return false
   }
 
-  if (!placeholder || placeholderLoaded.value) {
+  const placeholder = props.placeholder === '' ? [10, 10] : props.placeholder
+
+  if (!placeholder) {
     return false
   }
 
@@ -104,26 +82,26 @@ const placeholder = computed(() => {
     return placeholder
   }
 
-  const size = (Array.isArray(placeholder)
+  const [width = 10, height = width, quality = 50, blur = 3] = Array.isArray(placeholder)
     ? placeholder
-    : (typeof placeholder === 'number' ? [placeholder, placeholder] : [10, 10])) as [w: number, h: number, q: number, b: number]
+    : typeof placeholder === 'number' ? [placeholder] : []
 
   return $img(props.src!, {
-    ...modifiers.value,
-    width: size[0],
-    height: size[1],
-    quality: size[2] || 50,
-    blur: size[3] || 3,
+    ...imageModifiers.value,
+    width,
+    height,
+    quality,
+    blur,
   }, providerOptions.value)
 })
 
 const mainSrc = computed(() =>
   props.sizes
     ? sizes.value.src
-    : $img(props.src!, modifiers.value, providerOptions.value),
+    : $img(props.src!, imageModifiers.value, providerOptions.value),
 )
 
-const src = computed(() => placeholder.value ? placeholder.value : mainSrc.value)
+const src = computed(() => placeholder.value || mainSrc.value)
 
 if (import.meta.server && props.preload) {
   const isResponsive = Object.values(sizes.value).every(v => v)
@@ -203,3 +181,11 @@ onMounted(() => {
   }
 })
 </script>
+
+<script lang="ts">
+export interface DefaultSlotProps {
+  imgAttrs: ImgHTMLAttributes
+  isLoaded: boolean
+  src?: string
+}
+</script>
diff --git a/src/runtime/components/NuxtPicture.vue b/src/runtime/components/NuxtPicture.vue
index ef5b41b..dcc01cf 100644
--- a/src/runtime/components/NuxtPicture.vue
+++ b/src/runtime/components/NuxtPicture.vue
@@ -1,42 +1,38 @@
 <template>
   <picture v-bind="attrs.picture">
-    <source
-      v-for="source in sources.slice(0, -1)"
-      :key="source.src"
-      :type="source.type"
-      :sizes="source.sizes"
-      :srcset="source.srcset"
-    >
-
-    <img
-      v-if="lastSource"
-      ref="imgEl"
-      v-bind="{
-        ...baseAttrs,
-        ...(isServer ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {}),
-        ...attrs.img,
-        'data-nuxt-pic': '',
-      }"
-      :src="lastSource.src"
-      :sizes="lastSource.sizes"
-      :srcset="lastSource.srcset"
-    >
+    <template v-for="(source, index) of sources">
+      <source
+        v-if="index + 1 < sources.length"
+        :key="source.src"
+        :type="source.type"
+        :sizes="source.sizes"
+        :srcset="source.srcset"
+      >
+      <img
+        v-else
+        ref="imgEl"
+        :key="'last' + source.src"
+        v-bind="attrs.img"
+        :src="source.src"
+        :sizes="source.sizes"
+        :srcset="source.srcset"
+      >
+    </template>
   </picture>
 </template>
 
 <script setup lang="ts" generic="Provider extends keyof ConfiguredImageProviders = ProviderDefaults['provider']">
 import type { SerializableHead } from '@unhead/vue/types'
+import type { ImgHTMLAttributes } from 'vue'
+import type { ConfiguredImageProviders, ProviderDefaults } from '@nuxt/image'
 
 import { computed, onMounted, useAttrs, useTemplateRef } from 'vue'
-import type { ImgHTMLAttributes } from 'vue'
 
-import type { ConfiguredImageProviders, ProviderDefaults } from '@nuxt/image'
 import { prerenderStaticImages } from '../utils/prerender'
 import { markFeatureUsage } from '../utils/performance'
-import { getFileExtension } from '../utils'
 import { useImage } from '../composables'
-import { useImageModifiers, useNormalisedAttrs, useProviderOptions } from './_base'
-import type { BaseImageProps } from './_base'
+import { useImageProps } from '../utils/props'
+import type { BaseImageProps } from '../utils/props'
 
 import { useHead, useNuxtApp, useRequestEvent } from '#imports'
 
@@ -46,6 +42,7 @@ export interface PictureProps<Provider extends keyof ConfiguredImageProviders> e
 }
 
 defineOptions({ inheritAttrs: false })
+defineSlots<{ default(props: DefaultSlotProps): any }>()
 
 const props = defineProps<PictureProps<Provider>>()
 
@@ -54,18 +51,19 @@ const emit = defineEmits<{
   (event: 'error', payload: string | Event): unknown
 }>()
 
-const isServer = import.meta.server
-
-const baseAttrs = useNormalisedAttrs(props)
-
-// Only pass down supported <image> attributes
 const _attrs = useAttrs()
 const imageAttrNames = new Set(['alt', 'referrerpolicy', 'usemap', 'longdesc', 'ismap', 'loading', 'crossorigin', 'decoding', 'nonce'])
 const attrs = computed(() => {
   const attrs: Record<string, Record<string, unknown>> = {
-    img: { ...props.imgAttrs },
+    img: {
+      ...normalizedAttrs.value,
+      ...props.imgAttrs,
+      ...(import.meta.server ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {}),
+      'data-nuxt-pic': '',
+    },
     picture: {},
   }
+  // Only pass down supported <image> attributes
   for (const key in _attrs) {
     if (imageAttrNames.has(key)) {
       if (!(key in attrs.img)) {
@@ -79,27 +77,21 @@ const attrs = computed(() => {
   return attrs
 })
 
-defineSlots<{
-  default(props: DefaultSlotProps): any
-}>()
-
-const originalFormat = computed(() => getFileExtension(props.src))
-const isTransparent = computed(() => ['png', 'webp', 'gif', 'svg'].includes(originalFormat.value))
+const originalFormat = computed(() => props.src?.match(/^[^?#]+\.(\w+)(?:$|[?#])/)?.[1])
 
 const legacyFormat = computed(() => {
   if (props.legacyFormat) {
     return props.legacyFormat
   }
 
-  return isTransparent.value ? 'png' : 'jpeg'
+  const isNotTransparent = !originalFormat.value || !['png', 'webp', 'gif', 'svg'].includes(originalFormat.value)
+  return isNotTransparent ? 'jpeg' : 'png'
 })
 
-type Source = { src?: string, srcset?: string, type?: string, sizes?: string }
-
 const $img = useImage()
-const providerOptions = useProviderOptions(props)
-const modifiers = useImageModifiers(props)
+const { providerOptions, imageModifiers, normalizedAttrs } = useImageProps(props)
 
+type Source = { src?: string, srcset?: string, type?: string, sizes?: string }
 const sources = computed<Source[]>(() => {
   const formats = props.format?.split(',') || (originalFormat.value === 'svg' ? ['svg'] : ($img.options.format?.length ? [...$img.options.format] : ['webp']))
 
@@ -120,15 +112,13 @@ const sources = computed<Source[]>(() => {
       ...providerOptions.value,
       sizes: props.sizes || $img.options.screens,
       densities: props.densities,
-      modifiers: { ...modifiers.value, format },
+      modifiers: { ...imageModifiers.value, format },
     })
 
     return { src, type: `image/${format}`, sizes, srcset }
   })
 })
 
-const lastSource = computed(() => sources.value[sources.value.length - 1])
-
 if (import.meta.server && props.preload) {
   useHead({ link: () => {
     const firstSource = sources.value[0]
@@ -165,12 +155,13 @@ const initialLoad = nuxtApp.isHydrating
 
 const imgEl = useTemplateRef('imgEl')
 onMounted(() => {
-  if (!imgEl.value) {
+  const el = Array.isArray(imgEl.value) ? imgEl.value[0] as HTMLImageElement | undefined : imgEl.value
+  if (!el) {
     return
   }
 
-  if (imgEl.value.complete && initialLoad) {
-    if (imgEl.value.getAttribute('data-error')) {
+  if (el.complete && initialLoad) {
+    if (el.getAttribute('data-error')) {
       emit('error', new Event('error'))
     }
     else {
@@ -178,11 +169,11 @@ onMounted(() => {
     }
   }
 
-  imgEl.value.onload = (event) => {
+  el.onload = (event) => {
     emit('load', event)
   }
 
-  imgEl.value.onerror = (event) => {
+  el.onerror = (event) => {
     emit('error', event)
   }
 
@@ -192,7 +183,7 @@ onMounted(() => {
 
 <script lang="ts">
 export interface DefaultSlotProps {
-  imgAttrs: Record<string, unknown>
+  imgAttrs: ImgHTMLAttributes
   isLoaded: boolean
   src?: string
 }
diff --git a/src/runtime/image.ts b/src/runtime/image.ts
index f316859..6dd7bb8 100644
--- a/src/runtime/image.ts
+++ b/src/runtime/image.ts
@@ -21,12 +21,7 @@ export function createImage(globalOptions: CreateImageOptions) {
     return image
   }
 
-  const $img = ((input, modifiers = {}, options = {}) => {
-    return getImage(input, {
-      ...options,
-      modifiers: defu(modifiers, options.modifiers || {}),
-    }).url
-  }) as $Img
+  const $img = ((input, modifiers, options) => getImage(input, defu({ modifiers }, options)).url) as $Img
 
   for (const presetName in globalOptions.presets) {
     $img[presetName] = ((source, modifiers, options) =>
@@ -98,20 +93,15 @@ function resolveImage(ctx: ImageCTX, input: string, options: ImageOptions): Reso
   const _options = defu(options, preset, defaults as Partial<ImageOptions>)
   const resolvedOptions = {
     ..._options,
-    modifiers: { ..._options.modifiers },
-  }
-
-  const expectedFormat = resolvedOptions.modifiers.format
-  if (resolvedOptions.modifiers?.width) {
-    resolvedOptions.modifiers.width = parseSize(resolvedOptions.modifiers.width)
-  }
-  if (resolvedOptions.modifiers?.height) {
-    resolvedOptions.modifiers.height = parseSize(resolvedOptions.modifiers.height)
+    modifiers: {
+      ..._options.modifiers,
+      width: _options.modifiers?.width ? parseSize(_options.modifiers.width) : undefined,
+      height: _options.modifiers?.height ? parseSize(_options.modifiers.height) : undefined,
+    },
   }
 
   const image = provider.getImage(input, resolvedOptions, ctx)
-
-  image.format = image.format || expectedFormat || ''
+  image.format ||= resolvedOptions.modifiers.format || ''
 
   return image
 }
diff --git a/src/runtime/index.ts b/src/runtime/index.ts
index e790392..96831a5 100644
--- a/src/runtime/index.ts
+++ b/src/runtime/index.ts
@@ -1,3 +1,5 @@
-export * from './image'
-export * from './provider'
-export * from './utils/index'
+export { createImage } from './image'
+export { defineProvider } from './utils/provider'
+export { createMapper, createOperationsGenerator } from './utils/index'
+
+export type { Mapper } from './utils/index'
diff --git a/src/runtime/plugin.ts b/src/runtime/plugins/image.ts
similarity index 100%
rename from src/runtime/plugin.ts
rename to src/runtime/plugins/image.ts
diff --git a/src/runtime/providers/ipx.ts b/src/runtime/providers/ipx.ts
index 7c92118..dbc8076 100644
--- a/src/runtime/providers/ipx.ts
+++ b/src/runtime/providers/ipx.ts
@@ -5,7 +5,7 @@ import { defineProvider, createOperationsGenerator } from '#image'
 
 // Reference: https://github.com/unjs/ipx?tab=readme-ov-file#modifiers
 // TODO: https://github.com/unjs/ipx/issues/199
-export interface IPXModifiers extends Omit<ImageModifiers, 'fit' | 'format'> {
+export interface IPXModifiers extends Omit<ImageModifiers, 'fit' | 'format' | 'blur'> {
   format: 'jpeg' | 'jpg' | 'png' | 'webp' | 'avif' | 'gif' | 'heif' | 'tiff' | 'auto' | string & {}
   fit: 'contain' | 'cover' | 'fill' | 'inside' | 'outside' | string & {}
   resize: string
diff --git a/src/runtime/providers/strapi.ts b/src/runtime/providers/strapi.ts
index 934823e..cf41a05 100644
--- a/src/runtime/providers/strapi.ts
+++ b/src/runtime/providers/strapi.ts
@@ -1,5 +1,5 @@
 import { withBase, withoutLeadingSlash } from 'ufo'
-import { defineProvider } from '../provider'
+import { defineProvider } from '../utils/provider'
 
 // https://strapi.io/documentation/developer-docs/latest/development/plugins/upload.html#upload
 
diff --git a/src/runtime/providers/twicpics.ts b/src/runtime/providers/twicpics.ts
index 53936ea..e52ecdf 100644
--- a/src/runtime/providers/twicpics.ts
+++ b/src/runtime/providers/twicpics.ts
@@ -1,6 +1,6 @@
 import { encodeQueryItem, joinURL } from 'ufo'
-import { defineProvider } from '#image'
-import { createMapper, createOperationsGenerator, type InferModifiers } from '#image'
+import { defineProvider, createMapper, createOperationsGenerator } from '#image'
+import type { InferModifiers } from '#image/utils/index'
 
 const fits = createMapper({
   fill: 'resize',
diff --git a/src/runtime/ipx.ts b/src/runtime/server/routes/_ipx.ts
similarity index 100%
rename from src/runtime/ipx.ts
rename to src/runtime/server/routes/_ipx.ts
diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts
index bfc4412..4423225 100644
--- a/src/runtime/utils/index.ts
+++ b/src/runtime/utils/index.ts
@@ -1,28 +1,5 @@
 import type { OperationGeneratorConfig } from '@nuxt/image'
 
-export default function imageFetch(url: string) {
-  return fetch(cleanDoubleSlashes(url))
-}
-
-export function getInt(x: unknown): number | undefined {
-  if (typeof x === 'number') {
-    return x
-  }
-  if (typeof x === 'string') {
-    return Number.parseInt(x, 10)
-  }
-  return undefined
-}
-
-export function getFileExtension(url = '') {
-  const extension = url.split(/[?#]/).shift()!.split('/').pop()!.split('.').pop()!
-  return extension
-}
-
-export function cleanDoubleSlashes(path = '') {
-  return path.replace(/(https?:\/\/)|(\/)+/g, '$1$2')
-}
-
 export interface Mapper<Key, Value> {
   (key: Key): Value | Key
   (): undefined
@@ -69,37 +46,6 @@ export function createOperationsGenerator<ModifierKey extends string, ModifierVa
 
 export type InferModifiers<T extends (modifiers: any) => string> = T extends (modifiers: infer Modifiers) => string ? Modifiers : Record<string, unknown>
 
-type Attrs = { [key: string]: string | number }
-
-export function renderAttributesToString(attributes: Attrs = {}) {
-  return Object.entries(attributes)
-    .map(([key, value]) => value ? `${key}="${value}"` : '')
-    .filter(Boolean).join(' ')
-}
-
-export function renderTag(tag: string, attrs: Attrs, contents?: string) {
-  const html = `<${tag} ${renderAttributesToString(attrs)}>`
-  if (!contents) {
-    return html
-  }
-  return html + contents + `</${tag}>`
-}
-
-export function generateAlt(src = '') {
-  return src.split(/[?#]/).shift()!.split('/').pop()!.split('.').shift()
-}
-
-export function parseSize(input: string | number | undefined = '') {
-  if (typeof input === 'number') {
-    return input
-  }
-  if (typeof input === 'string') {
-    if (input.replace('px', '').match(/^\d+$/g)) {
-      return Number.parseInt(input, 10)
-    }
-  }
-}
-
 export function parseDensities(input: string | undefined = ''): number[] {
   if (input === undefined || !input.length) {
     return []
@@ -129,6 +75,17 @@ export function checkDensities(densities: number[]) {
   }
 }
 
+export function parseSize(input: string | number | undefined = '') {
+  if (typeof input === 'number') {
+    return input
+  }
+  if (typeof input === 'string') {
+    if (input.replace('px', '').match(/^\d+$/g)) {
+      return Number.parseInt(input, 10)
+    }
+  }
+}
+
 export function parseSizes(input: Record<string, string | number> | string): Record<string, string> {
   const sizes: Record<string, string> = {}
   // string => object
diff --git a/src/runtime/components/_base.ts b/src/runtime/utils/props.ts
similarity index 68%
rename from src/runtime/components/_base.ts
rename to src/runtime/utils/props.ts
index a463043..2ba8f0c 100644
--- a/src/runtime/components/_base.ts
+++ b/src/runtime/utils/props.ts
@@ -1,6 +1,6 @@
 import { computed } from 'vue'
 import type { ConfiguredImageProviders, ImageModifiers } from '@nuxt/image'
-import { parseSize } from '../utils'
+import { parseSize } from '.'
 import { useImage } from '#imports'
 
 export interface BaseImageProps<Provider extends keyof ConfiguredImageProviders> {
@@ -32,34 +32,32 @@ export interface BaseImageProps<Provider extends keyof ConfiguredImageProviders>
   nonce?: string
 }
 
-export const useProviderOptions = <Provider extends keyof ConfiguredImageProviders>(props: Pick<BaseImageProps<Provider>, 'provider' | 'preset'>) => computed(() => {
-  return {
+export const useImageProps = <Provider extends keyof ConfiguredImageProviders>(props: BaseImageProps<Provider>) => {
+  const $img = useImage()
+
+  const providerOptions = computed(() => ({
     provider: props.provider,
     preset: props.preset,
-  }
-})
+  }))
 
-export const useNormalisedAttrs = (props: Pick<BaseImageProps<any>, 'width' | 'height' | 'crossorigin' | 'nonce'>) => computed(() => {
-  return {
+  const normalizedAttrs = computed(() => ({
     width: parseSize(props.width),
     height: parseSize(props.height),
     crossorigin: props.crossorigin === true ? 'anonymous' : props.crossorigin || undefined,
     nonce: props.nonce,
-  }
-})
+  }))
 
-export const useImageModifiers = (props: Pick<BaseImageProps<any>, 'width' | 'height' | 'modifiers' | 'format' | 'quality' | 'fit' | 'background'>) => {
-  const $img = useImage()
-
-  return computed(() => {
+  const imageModifiers = computed(() => {
     return {
       ...props.modifiers,
-      width: parseSize(props.width),
-      height: parseSize(props.height),
+      width: props.width,
+      height: props.height,
       format: props.format,
       quality: props.quality || $img.options.quality,
       background: props.background,
       fit: props.fit,
-    }
+    } satisfies Partial<ImageModifiers>
   })
+
+  return { providerOptions, normalizedAttrs, imageModifiers }
 }
diff --git a/src/runtime/provider.ts b/src/runtime/utils/provider.ts
similarity index 85%
rename from src/runtime/provider.ts
rename to src/runtime/utils/provider.ts
index 2b37cec..24b592b 100644
--- a/src/runtime/provider.ts
+++ b/src/runtime/utils/provider.ts
@@ -1,4 +1,4 @@
-import type { ImageProvider } from '../module'
+import type { ImageProvider } from '../../module'
 
 export function defineProvider<T>(setup: ImageProvider<T> | (() => ImageProvider<T>)): () => ImageProvider<T> {
   let result: ImageProvider<T>
diff --git a/src/types/image.ts b/src/types/image.ts
index 837051e..b509553 100644
--- a/src/types/image.ts
+++ b/src/types/image.ts
@@ -3,12 +3,18 @@ import type { H3Event } from 'h3'
 import type { ConfiguredImageProviders } from './module'
 
 export interface ImageModifiers {
-  width: number
-  height: number
+  width: number | string
+  height: number | string
   fit: string
   format: string
   quality: string | number
   background: string
+  blur: number
+}
+
+export interface ResolvedImageModifiers extends ImageModifiers {
+  width: number
+  height: number
 }
 
 export interface ImageOptions {
@@ -23,7 +29,7 @@ export interface ImageSizesOptions extends ImageOptions {
   sizes: Record<string, string | number> | string
 }
 
-export type ProviderGetImage<T = Record<string, unknown>> = (src: string, options: ImageOptions & { modifiers: NonNullable<ImageOptions['modifiers']> } & T, ctx: ImageCTX) => ResolvedImage
+export type ProviderGetImage<T = Record<string, unknown>> = (src: string, options: ImageOptions & { modifiers: Partial<ResolvedImageModifiers> } & T, ctx: ImageCTX) => ResolvedImage
 
 interface ImageModifierOptions {
   modifiers?: Record<string, unknown>


Test patch:
diff --git a/test/e2e/bundle.test.ts b/test/e2e/bundle.test.ts
index 63275a0..f38b10d 100644
--- a/test/e2e/bundle.test.ts
+++ b/test/e2e/bundle.test.ts
@@ -7,8 +7,9 @@ import { buildNuxt, loadNuxt } from '@nuxt/kit'
 import type { NuxtConfig } from '@nuxt/schema'
 import { describe, it, expect } from 'vitest'
 import { glob } from 'tinyglobby'
+import { isWindows } from 'std-env'
 
-describe.skipIf(process.env.ECOSYSTEM_CI)('nuxt image bundle size', () => {
+describe.skipIf(process.env.ECOSYSTEM_CI || isWindows)('nuxt image bundle size', () => {
   it('should match snapshot', { timeout: 120_000 }, async () => {
     const rootDir = fileURLToPath(new URL('../.tmp', import.meta.url))
     await fsp.rm(rootDir, { recursive: true, force: true })
@@ -21,7 +22,7 @@ describe.skipIf(process.env.ECOSYSTEM_CI)('nuxt image bundle size', () => {
       }),
     ])
 
-    expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.7k"`)
+    expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.4k"`)
   })
 })
 
diff --git a/test/nuxt/picture.test.ts b/test/nuxt/picture.test.ts
index 2dd0f58..eb1a54c 100644
--- a/test/nuxt/picture.test.ts
+++ b/test/nuxt/picture.test.ts
@@ -48,7 +48,7 @@ describe('Renders simple image', () => {
   it('Matches snapshot', () => {
     expect(wrapper.html()).toMatchInlineSnapshot(`
       "<picture>
-        <source type="image/webp" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_webp&amp;s_200x200/image.png 200w, /_ipx/f_webp&amp;s_400x400/image.png 400w, /_ipx/f_webp&amp;s_500x500/image.png 500w, /_ipx/f_webp&amp;s_900x900/image.png 900w, /_ipx/f_webp&amp;s_1000x1000/image.png 1000w, /_ipx/f_webp&amp;s_1800x1800/image.png 1800w"><img width="200" height="200" loading="lazy" data-nuxt-pic="" src="/_ipx/f_png&amp;s_1800x1800/image.png" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_png&amp;s_200x200/image.png 200w, /_ipx/f_png&amp;s_400x400/image.png 400w, /_ipx/f_png&amp;s_500x500/image.png 500w, /_ipx/f_png&amp;s_900x900/image.png 900w, /_ipx/f_png&amp;s_1000x1000/image.png 1000w, /_ipx/f_png&amp;s_1800x1800/image.png 1800w">
+        <source type="image/webp" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_webp&amp;s_200x200/image.png 200w, /_ipx/f_webp&amp;s_400x400/image.png 400w, /_ipx/f_webp&amp;s_500x500/image.png 500w, /_ipx/f_webp&amp;s_900x900/image.png 900w, /_ipx/f_webp&amp;s_1000x1000/image.png 1000w, /_ipx/f_webp&amp;s_1800x1800/image.png 1800w"><img width="200" height="200" data-nuxt-pic="" loading="lazy" src="/_ipx/f_png&amp;s_1800x1800/image.png" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_png&amp;s_200x200/image.png 200w, /_ipx/f_png&amp;s_400x400/image.png 400w, /_ipx/f_png&amp;s_500x500/image.png 500w, /_ipx/f_png&amp;s_900x900/image.png 900w, /_ipx/f_png&amp;s_1000x1000/image.png 1000w, /_ipx/f_png&amp;s_1800x1800/image.png 1800w">
       </picture>"
     `)
   })
@@ -145,7 +145,7 @@ describe('Renders simple image', () => {
     })
     expect(img.html()).toMatchInlineSnapshot(`
       "<picture>
-        <source type="image/webp" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_webp&amp;s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/f_webp&amp;s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/f_webp&amp;s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&amp;s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_webp&amp;s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_webp&amp;s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w"><img width="200" height="200" loading="lazy" data-nuxt-pic="" src="/_ipx/f_png&amp;s_1800x1800/%E6%B1%89%E5%AD%97.png" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_png&amp;s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/f_png&amp;s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/f_png&amp;s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&amp;s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_png&amp;s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_png&amp;s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w">
+        <source type="image/webp" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_webp&amp;s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/f_webp&amp;s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/f_webp&amp;s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&amp;s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_webp&amp;s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_webp&amp;s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w"><img width="200" height="200" data-nuxt-pic="" loading="lazy" src="/_ipx/f_png&amp;s_1800x1800/%E6%B1%89%E5%AD%97.png" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/f_png&amp;s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/f_png&amp;s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/f_png&amp;s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&amp;s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_png&amp;s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_png&amp;s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w">
       </picture>"
     `)
   })
diff --git a/test/nuxt/providers.test.ts b/test/nuxt/providers.test.ts
index fe7db7b..b38dec2 100644
--- a/test/nuxt/providers.test.ts
+++ b/test/nuxt/providers.test.ts
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'
 
 import { images } from '../providers'
 
-import { cleanDoubleSlashes } from '#image/utils'
 import ipx from '#image/providers/ipx'
 import none from '#image/providers/none'
 import weserv from '#image/providers/weserv'
@@ -63,7 +62,6 @@ describe('Providers', () => {
     for (const image of images) {
       const [src, modifiers] = image.args
       const generated = ipx().getImage(src, { modifiers: { ...modifiers }, ...providerOptions }, emptyContext)
-      generated.url = cleanDoubleSlashes(generated.url)
       expect(generated).toMatchObject(image.ipx)
     }
   })
@@ -73,7 +71,6 @@ describe('Providers', () => {
 
     const src = '/images/test.png'
     const generated = ipx().getImage(src, { modifiers: {} }, context)
-    generated.url = cleanDoubleSlashes(generated.url)
     expect(generated).toMatchObject({
       url: '/_ipx/_/images/test.png',
     })