Skip to main content

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:

ts
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

nuxt.config.ts
nuxtPhoto: {
  image: false
}

This setting stops Nuxt Photo from registering an adapter at application start. You must provide the adapter.

2. Write the adapter

utils/photoAdapter.ts
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.width and PhotoItem.height accurate. Nuxt Photo uses the photo dimensions for layout and lightbox frame sizing. Adapter width and height are 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:

plugins/photo-adapter.ts
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:

vue
<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.

ts
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:

ts
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

ts
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:

ts
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)
  })
})