From 9b7406d4325a4baa5c82f8d5129eb91d28428e36 Mon Sep 17 00:00:00 2001
From: onmax
Date: Fri, 12 Dec 2025 12:56:01 +0100
Subject: [PATCH] feat(image): integrate @nuxt/image with hub blob
---
.gitignore | 5 +-
build.config.ts | 6 ++
docs/app/pages/templates.vue | 8 ---
docs/content/docs/2.features/0.blob.md | 64 +++++++++++++++++++
src/blob/setup.ts | 9 ++-
src/image/runtime/provider.ts | 50 +++++++++++++++
src/image/setup.ts | 60 +++++++++++++++++
src/types/config.ts | 27 ++++++--
test/fixtures/image/nuxt.config.ts | 19 ++++++
test/fixtures/image/package.json | 9 +++
.../image/server/routes/images/_url.get.ts | 15 +++++
test/image.e2e.test.ts | 37 +++++++++++
test/image.integration.test.ts | 45 +++++++++++++
13 files changed, 340 insertions(+), 14 deletions(-)
create mode 100644 src/image/runtime/provider.ts
create mode 100644 src/image/setup.ts
create mode 100644 test/fixtures/image/nuxt.config.ts
create mode 100644 test/fixtures/image/package.json
create mode 100644 test/fixtures/image/server/routes/images/_url.get.ts
create mode 100644 test/image.e2e.test.ts
create mode 100644 test/image.integration.test.ts
diff --git a/.gitignore b/.gitignore
index 5fe05337..d3e5ae35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,4 +60,7 @@ test/fixtures/basic/.data
test/fixtures/kv/.data
test/fixtures/blob/.data
test/fixtures/openapi/.data
-test/fixtures/cache/.data
\ No newline at end of file
+test/fixtures/cache/.data
+
+# Local PR notes
+pr-body.md
diff --git a/build.config.ts b/build.config.ts
index e7aa418a..89f64e7e 100644
--- a/build.config.ts
+++ b/build.config.ts
@@ -45,6 +45,12 @@ export default defineBuildConfig({
outDir: 'dist/blob/types',
builder: 'mkdist'
},
+ // Image
+ {
+ input: 'src/image/runtime/',
+ outDir: 'dist/image/runtime',
+ builder: 'mkdist'
+ },
// Cache
{
input: 'src/cache/runtime/',
diff --git a/docs/app/pages/templates.vue b/docs/app/pages/templates.vue
index d6bf99f0..d84d8130 100644
--- a/docs/app/pages/templates.vue
+++ b/docs/app/pages/templates.vue
@@ -7,7 +7,6 @@ interface Template {
repo: string
features: string[]
demoUrl: string
- workersPaid: boolean
slug: string
}
@@ -81,13 +80,6 @@ import.meta.server && defineOgImageComponent('Docs')
{{ template.description }}
-
` and `` components with automatic image optimization based on your hosting provider.
+
+### Setup
+
+1. Install `@nuxt/image` v2+:
+
+:pm-install{name="@nuxt/image"}
+
+::note
+`@nuxt/image` version 2 or higher is required for NuxtHub integration.
+::
+
+2. Add the module to your `nuxt.config.ts`:
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ modules: ['@nuxthub/core', '@nuxt/image'],
+ hub: {
+ blob: {
+ image: { path: '/images' }
+ }
+ }
+})
+```
+
+The `path` option defines the URL prefix where your blob images are served (e.g., `/images`). This should match the route you create in step 3. Images in blob storage can be stored at any path - the `path` option only controls the URL routing, not the storage structure.
+
+3. Create a route to serve blob images:
+
+```ts [server/routes/images/[...pathname].get.ts]
+import { blob } from 'hub:blob'
+
+export default defineEventHandler(async (event) => {
+ const pathname = getRouterParam(event, 'pathname')
+ return blob.serve(event, pathname!)
+})
+```
+
+4. Use `` in your components:
+
+```vue [pages/index.vue]
+
+
+
+```
+
+### Provider Configuration
+
+NuxtHub registers a `nuxthub` provider for `@nuxt/image` that routes to built-in providers based on your blob driver:
+
+- **Cloudflare R2** uses [Cloudflare Image Resizing](https://image.nuxt.com/providers/cloudflare)
+- **Vercel Blob** uses [Vercel Image Optimization](https://image.nuxt.com/providers/vercel)
+- **Filesystem / S3** pass-through (no optimization)
+
+::note
+To opt out or use a different provider, set `image.provider` explicitly in `nuxt.config.ts`.
+::
+
+### Development Mode
+
+During development, images are served directly from blob storage without any transformation, regardless of the configured driver. This ensures fast local development without requiring external services.
+
## Vue Composables
::note
diff --git a/src/blob/setup.ts b/src/blob/setup.ts
index c9c8ea8c..b959bbb3 100644
--- a/src/blob/setup.ts
+++ b/src/blob/setup.ts
@@ -5,6 +5,7 @@ import { addTypeTemplate, addServerImports, addImportsDir, logger, addTemplate }
import type { Nuxt } from '@nuxt/schema'
import type { HubConfig, ResolvedBlobConfig } from '@nuxthub/core'
import { resolve, logWhenReady } from '../utils'
+import { setupImage } from '../image/setup'
const log = logger.withTag('nuxt:hub')
@@ -43,6 +44,9 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record):
if (!deps['@vercel/blob']) {
log.error('Please run `npx nypm i @vercel/blob` to use Vercel Blob')
}
+ if (!process.env.BLOB_READ_WRITE_TOKEN) {
+ log.warn('Set BLOB_READ_WRITE_TOKEN env var for Vercel Blob storage')
+ }
return defu(hub.blob, {
driver: 'vercel-blob',
access: 'public'
@@ -64,7 +68,7 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record):
}) as ResolvedBlobConfig
}
-export function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record) {
+export async function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record) {
hub.blob = resolveBlobConfig(hub, deps)
if (!hub.blob) return
@@ -107,5 +111,8 @@ export const blob = createBlobStorage(createDriver(${JSON.stringify(driverOption
logWhenReady(nuxt, 'Files stored in Vercel Blob are public. Manually configure a different storage driver if storing sensitive files.', 'warn')
}
+ // Setup @nuxt/image provider
+ await setupImage(nuxt, hub, deps)
+
logWhenReady(nuxt, `\`hub:blob\` using \`${blobConfig.driver}\` driver`)
}
diff --git a/src/image/runtime/provider.ts b/src/image/runtime/provider.ts
new file mode 100644
index 00000000..155e2213
--- /dev/null
+++ b/src/image/runtime/provider.ts
@@ -0,0 +1,50 @@
+import { joinURL } from 'ufo'
+import { defineProvider } from '@nuxt/image/runtime'
+import cloudflareProvider from '@nuxt/image/runtime/providers/cloudflare'
+import vercelProvider from '@nuxt/image/runtime/providers/vercel'
+
+const cfProvider = cloudflareProvider()
+const vercelProviderInstance = vercelProvider()
+
+export function getImage(
+ src: string,
+ {
+ modifiers = {},
+ baseURL,
+ driver,
+ path
+ }: {
+ modifiers?: Record
+ baseURL?: string
+ driver?: string
+ path?: string
+ } = {},
+ ctx?: any
+) {
+ const hasProtocol = /^[a-z][a-z0-9+.-]*:/.test(src)
+ const isAbsolute = hasProtocol || src.startsWith('/')
+ const passThroughBase = path || baseURL || '/'
+ const resolvedSrc = isAbsolute ? src : joinURL(passThroughBase, src)
+
+ // Dev: pass-through (no optimization available locally)
+ if (import.meta.dev) {
+ return { url: resolvedSrc }
+ }
+
+ const safeCtx = ctx || { options: { screens: {}, domains: [] } }
+
+ // Cloudflare R2 -> CF Image Resizing
+ if (driver === 'cloudflare-r2') {
+ return cfProvider.getImage(resolvedSrc, { modifiers, baseURL }, safeCtx)
+ }
+
+ // Vercel Blob -> Vercel Image Optimization
+ if (driver === 'vercel-blob') {
+ return vercelProviderInstance.getImage(resolvedSrc, { modifiers, baseURL }, safeCtx)
+ }
+
+ // S3/FS or fallback: no optimization, pass-through
+ return { url: resolvedSrc }
+}
+
+export default defineProvider({ getImage })
diff --git a/src/image/setup.ts b/src/image/setup.ts
new file mode 100644
index 00000000..105ed2b7
--- /dev/null
+++ b/src/image/setup.ts
@@ -0,0 +1,60 @@
+import type { Nuxt } from '@nuxt/schema'
+import type { HubConfig, ResolvedBlobConfig } from '@nuxthub/core'
+import { resolvePath } from '@nuxt/kit'
+import { defu } from 'defu'
+import { resolve, logWhenReady } from '../utils'
+
+export async function setupImage(nuxt: Nuxt, hub: HubConfig, deps: Record) {
+ if (!hub.blob) return
+
+ const blobConfig = hub.blob as ResolvedBlobConfig
+ const imagePath = blobConfig.image?.path
+ if (!imagePath) return
+
+ const imageVersion = deps['@nuxt/image']
+ if (!imageVersion) {
+ logWhenReady(nuxt, 'Install @nuxt/image v2+ to enable NuxtHub image optimization', 'warn')
+ return
+ }
+
+ // Check if @nuxt/image v2+ by trying to resolve runtime path (v1 doesn't export it)
+ try {
+ await resolvePath('@nuxt/image/runtime')
+ } catch {
+ logWhenReady(nuxt, '@nuxt/image v2+ is required for NuxtHub integration. Please upgrade to @nuxt/image@^2.0.0', 'warn')
+ return
+ }
+
+ const normalizedPath = (imagePath.startsWith('/') ? imagePath : `/${imagePath}`)
+ .replace(/\/+$/, '') || '/'
+
+ // Register nuxthub provider for @nuxt/image
+ // @ts-expect-error image options from @nuxt/image
+ nuxt.options.image ||= {}
+ // @ts-expect-error image options from @nuxt/image
+ nuxt.options.image.providers ||= {}
+
+ // Respect any existing user config while ensuring required defaults.
+ // @ts-expect-error image options from @nuxt/image
+ const existingProvider = nuxt.options.image.providers.nuxthub as any | undefined
+ const providerPath = resolve('image/runtime/provider')
+
+ // If user configured a different provider under the "nuxthub" key, respect it.
+ const isCustomProvider = existingProvider?.provider && existingProvider.provider !== providerPath
+ if (!isCustomProvider) {
+ // @ts-expect-error image options from @nuxt/image
+ nuxt.options.image.providers.nuxthub = defu(existingProvider || {}, {
+ provider: providerPath,
+ options: { driver: blobConfig.driver, path: normalizedPath }
+ })
+ }
+
+ // Set as default provider if not already set
+ // @ts-expect-error image options from @nuxt/image
+ if (!nuxt.options.image.provider || nuxt.options.image.provider === 'auto') {
+ // @ts-expect-error image options from @nuxt/image
+ nuxt.options.image.provider = 'nuxthub'
+ }
+
+ logWhenReady(nuxt, `\`@nuxt/image\` nuxthub provider registered (${blobConfig.driver})`)
+}
diff --git a/src/types/config.ts b/src/types/config.ts
index 9de2de08..4e9438dd 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -70,11 +70,30 @@ export interface ModuleOptions {
hosting?: string
}
+// Blob image config for @nuxt/image integration
+export interface BlobImageConfig {
+ /**
+ * Path where blob images are served via your route handler.
+ * Enables automatic @nuxt/image configuration.
+ * @example '/images'
+ */
+ path: string
+}
+
+// Base blob config with shared options
+interface BaseBlobConfig {
+ /**
+ * @nuxt/image integration settings.
+ * Set `image.path` to match your blob serve route.
+ */
+ image?: BlobImageConfig
+}
+
// Blob driver configurations - extend from driver option types
-export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions
-export type S3BlobConfig = { driver: 's3' } & S3DriverOptions
-export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions
-export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2' } & CloudflareDriverOptions
+export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions & BaseBlobConfig
+export type S3BlobConfig = { driver: 's3' } & S3DriverOptions & BaseBlobConfig
+export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions & BaseBlobConfig
+export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2' } & CloudflareDriverOptions & BaseBlobConfig
export type BlobConfig = boolean | FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig
export type ResolvedBlobConfig = FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig
diff --git a/test/fixtures/image/nuxt.config.ts b/test/fixtures/image/nuxt.config.ts
new file mode 100644
index 00000000..f0b1a886
--- /dev/null
+++ b/test/fixtures/image/nuxt.config.ts
@@ -0,0 +1,19 @@
+import { defineNuxtConfig } from 'nuxt/config'
+
+export default defineNuxtConfig({
+ extends: [
+ '../basic'
+ ],
+ modules: [
+ '../../../src/module',
+ '@nuxt/image'
+ ],
+ hub: {
+ blob: {
+ driver: 'fs',
+ image: {
+ path: '/images'
+ }
+ }
+ }
+})
diff --git a/test/fixtures/image/package.json b/test/fixtures/image/package.json
new file mode 100644
index 00000000..6cbc0678
--- /dev/null
+++ b/test/fixtures/image/package.json
@@ -0,0 +1,9 @@
+{
+ "private": true,
+ "name": "hub-image-test",
+ "type": "module",
+ "devDependencies": {
+ "@nuxt/image": "*"
+ }
+}
+
diff --git a/test/fixtures/image/server/routes/images/_url.get.ts b/test/fixtures/image/server/routes/images/_url.get.ts
new file mode 100644
index 00000000..f86cd6d6
--- /dev/null
+++ b/test/fixtures/image/server/routes/images/_url.get.ts
@@ -0,0 +1,15 @@
+import { defineEventHandler, getQuery } from 'h3'
+import { useImage } from '#imports'
+
+export default defineEventHandler((event) => {
+ const query = getQuery(event)
+ const driver = typeof query.driver === 'string' ? query.driver : undefined
+ const width = typeof query.w === 'string' ? Number(query.w) : 300
+ const quality = typeof query.q === 'string' ? Number(query.q) : 80
+ const src = typeof query.src === 'string' ? query.src : '/images/photo.jpg'
+
+ const img = useImage(event)
+ const url = img(src, { width, quality }, { provider: 'nuxthub', driver })
+
+ return { url }
+})
diff --git a/test/image.e2e.test.ts b/test/image.e2e.test.ts
new file mode 100644
index 00000000..9022c277
--- /dev/null
+++ b/test/image.e2e.test.ts
@@ -0,0 +1,37 @@
+import { fileURLToPath } from 'node:url'
+import { describe, it, expect } from 'vitest'
+import { setup, $fetch } from '@nuxt/test-utils'
+
+describe('Image provider e2e', async () => {
+ await setup({
+ rootDir: fileURLToPath(new URL('./fixtures/image', import.meta.url)),
+ dev: false
+ })
+
+ it('generates Cloudflare Image Resizing URL for R2', async () => {
+ const { url } = await $fetch<{ url: string }>('/images/_url', {
+ query: { driver: 'cloudflare-r2', w: 300, q: 80 }
+ })
+ expect(url).toContain('/cdn-cgi/image/')
+ expect(url).toContain('w=300')
+ expect(url).toContain('q=80')
+ expect(url).toContain('/images/photo.jpg')
+ })
+
+ it('generates Vercel Image Optimization URL for Vercel Blob', async () => {
+ const { url } = await $fetch<{ url: string }>('/images/_url', {
+ query: { driver: 'vercel-blob', w: 300, q: 80 }
+ })
+ expect(url.startsWith('/_vercel/image?')).toBe(true)
+ expect(url).toContain('url=%2Fimages%2Fphoto.jpg')
+ expect(url).toMatch(/[?&]w=\d+/)
+ expect(url).toContain('q=80')
+ })
+
+ it('passes through URL for other drivers', async () => {
+ const { url } = await $fetch<{ url: string }>('/images/_url', {
+ query: { driver: 's3', w: 300, q: 80 }
+ })
+ expect(url).toBe('/images/photo.jpg')
+ })
+})
diff --git a/test/image.integration.test.ts b/test/image.integration.test.ts
new file mode 100644
index 00000000..d7cfdcc8
--- /dev/null
+++ b/test/image.integration.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest'
+import buildConfig from '../build.config'
+import { setupImage } from '../src/image/setup'
+
+describe('Image integration', () => {
+ it('ships image runtime provider in build config', () => {
+ const configs = buildConfig as any[]
+ const entries = configs[0]?.entries as any[]
+ const hasImageRuntime = entries.some((entry) => {
+ return typeof entry === 'object'
+ && entry.input === 'src/image/runtime/'
+ && entry.outDir === 'dist/image/runtime'
+ })
+ expect(hasImageRuntime).toBe(true)
+ })
+
+ it('registers nuxthub provider only when image.path is set', async () => {
+ const deps = { '@nuxt/image': '^2.0.0' }
+
+ const nuxtNoPath: any = { options: { _prepare: false, dev: false } }
+ const hubNoPath: any = { blob: { driver: 'fs' } }
+ await setupImage(nuxtNoPath, hubNoPath, deps)
+ expect(nuxtNoPath.options.image).toBeUndefined()
+
+ const nuxtWithPath: any = { options: { _prepare: false, dev: false } }
+ const hubWithPath: any = { blob: { driver: 'fs', image: { path: 'images/' } } }
+ await setupImage(nuxtWithPath, hubWithPath, deps)
+
+ expect(nuxtWithPath.options.image.provider).toBe('nuxthub')
+ expect(nuxtWithPath.options.image.providers?.nuxthub).toBeDefined()
+ expect(nuxtWithPath.options.image.providers.nuxthub.provider).toContain('image/runtime/provider')
+ expect(nuxtWithPath.options.image.providers.nuxthub.options).toMatchObject({
+ driver: 'fs',
+ path: '/images'
+ })
+ })
+
+ it('skips setup when @nuxt/image is not installed', async () => {
+ const deps = {} // no @nuxt/image
+ const nuxt: any = { options: { _prepare: false, dev: false } }
+ const hub: any = { blob: { driver: 'fs', image: { path: '/images' } } }
+ await setupImage(nuxt, hub, deps)
+ expect(nuxt.options.image).toBeUndefined()
+ })
+})