Integrate a custom image service
Route every image through your own CMS, signing logic, or format-selection rules.
The built-in 'native' and 'nuxt-image' providers cover most cases. Write a custom image adapter when you need signed URLs with short TTLs, a custom image API, or independent WebP and AVIF negotiation.
An adapter is a single function:
type ImageAdapter = (photo: PhotoItem, context: 'thumb' | 'slide') => ImageSource
type ImageSource = {
src: string
placeholderSrc?: string
srcset?: string
sizes?: string
width?: number
height?: number
}Given a photo and the context it will render in, return what the <img> should use.
1. Disable the built-in adapter
nuxtPhoto: {
image: false
}This setting stops Nuxt Photo from registering an adapter at application start. You must provide the adapter.
2. Write the adapter
import type { ImageAdapter, PhotoItem } from '@lupinum/nuxt-photo/app'
const BASE = 'https://cdn.example.com/transform'
function url(photo: PhotoItem, w: number, fmt = 'webp') {
return `${BASE}/${photo.id}?w=${w}&fmt=${fmt}`
}
export const cmsAdapter: ImageAdapter = (photo, context) => {
if (context === 'thumb') {
return {
src: url(photo, 480),
srcset: `${url(photo, 480)} 480w, ${url(photo, 960)} 960w`,
width: photo.width,
height: photo.height,
}
}
// slide
return {
src: url(photo, 1920),
srcset: [480, 960, 1440, 1920, 2560].map((w) => `${url(photo, w)} ${w}w`).join(', '),
sizes: '100vw',
width: photo.width,
height: photo.height,
}
}A few principles:
- Branch on
context. Grid thumbnails do not need full-resolution source sets. Lightbox slides do. - Keep
PhotoItem.widthandPhotoItem.heightaccurate. Nuxt Photo uses the photo dimensions for layout and lightbox frame sizing. Adapterwidthandheightare copied onto the rendered<img>. - Always return a concrete
src. If your upstream asset should render a placeholder, make that an explicit rule in the adapter.
3. Provide it globally
Use a Nuxt plugin that runs for the app, not a client-only plugin:
import { ImageAdapterKey } from '@lupinum/nuxt-photo/app'
import { cmsAdapter } from '~/utils/photoAdapter'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.provide(ImageAdapterKey, cmsAdapter)
})Every <PhotoImage> in the app now routes through cmsAdapter in both SSR and client rendering.
Nuxt applications import ImageAdapterKey from the supported
@lupinum/nuxt-photo/app facade.
4. Per-instance override
Pass an :image-adapter prop to ready-made components that render images, or to <PhotoImage> when you own the lower-level markup:
<script setup lang="ts">
import { cmsAdapter } from '~/utils/photoAdapter'
import { stockAdapter } from '~/utils/stockAdapter'
</script>
<template>
<PhotoAlbum :photos="cmsPhotos" :image-adapter="cmsAdapter" />
<PhotoAlbum :photos="stockPhotos" :image-adapter="stockAdapter" />
</template>Prop wins over the provided adapter, which wins over the module default.
Lower-level lightbox compositions can also pass the adapter to provideLightbox() or <LightboxProvider>.
Common adapter patterns
Signed URLs with expiration
Keep the adapter deterministic during render. Do not call Date.now() inside the adapter in an SSR app, because the server render and client hydration can compute different URLs. Sign the final URLs before rendering - in Nitro, your CMS layer, or your data loader - and put them on the photo.
const signedAdapter: ImageAdapter = (photo, context) => {
return {
src: context === 'thumb' ? (photo.thumbSrc ?? photo.src) : photo.src,
width: photo.width,
height: photo.height,
}
}Format negotiation (AVIF → WebP → JPEG)
The image adapter cannot return a <picture>-style source set because it renders an <img> element. Instead, let the server negotiate through the Accept header and return one URL for each context.
Cloudinary via hand-written URL
Use this example when you need Cloudinary without a dependency on @nuxt/image:
const CLOUD = 'your-cloud'
const cloudinaryAdapter: ImageAdapter = (photo, context) => {
const transform =
context === 'thumb' ? 'c_fill,w_480,h_360,q_auto,f_auto' : 'c_limit,w_1920,q_auto,f_auto'
return {
src: `https://res.cloudinary.com/${CLOUD}/image/upload/${transform}/${photo.src}`,
width: photo.width,
height: photo.height,
}
}Explicit placeholder rule
const safeAdapter: ImageAdapter = (photo, context) => {
if (photo.meta?.placeholder) {
return {
src: '/placeholder.svg',
width: photo.width,
height: photo.height,
}
}
// delegate to another adapter
return cmsAdapter(photo, context)
}Testing an adapter
Adapters are pure functions - unit-test them without Vue:
import { describe, it, expect } from 'vitest'
import { cmsAdapter } from '~/utils/photoAdapter'
const photo = { id: '1', src: 'x', width: 1280, height: 800 }
describe('cmsAdapter', () => {
it('emits a small srcset for thumbs', () => {
const result = cmsAdapter(photo, 'thumb')
expect(result.src).toContain('w=480')
expect(result.srcset).toMatch(/480w.*960w/)
})
it('emits a full srcset for slides', () => {
const result = cmsAdapter(photo, 'slide')
expect(result.srcset?.split(', ').length).toBeGreaterThan(3)
})
})