Using image loader is Next.js

Article Logo

Next.js has managed to kill off create-react-app and despite new rivals (in the face of Remix) is a de-facto way to start new React apps.

One of the awesome features that Next.js and its platform Vercel provides is a way to automatically optimize images for different screens to make both user and developer experience much smoother.

However, if your app has any non-trivial number of images that are generated by users you may soon end up visiting Vercel's limits page. Turns out that even on Hobby and Pro plans you are limited to 1000 and 5000 images per month, any overage is going to cost you dearly.

At this point, you are going to start shopping for alternative image optimization solutions. You can either host your own via Thumbor or imgproxy or use one of the hosted solutions like Cloudinary.

Regardless of what service you choose, it needs to be integrated into Next.js.

After taking a look at Cloudinary as an example, even their free plan offers 25k transformations a month and 1000 overage costs $0.40 instead of $9, while providing more features.

Next.js documentation talks about using a built-in Cloudinary loader. Unfortunately, I found that it is beyond primitive and simply specifying the loader is not enough, as it won't work in development, and you are missing out on any juicy features that other providers might have.

So in this article, I am going to show you an example of how to implement your own Next.js image loader using Cloudinary as an example.


import NextImage from 'next/image'
import { useCallback } from 'react'
import compact from 'lodash/compact'

const CLOUDINARY_HOST = 'https://some-domain.mo.cloudinary.net'

type ImageLoaderProps = {
  src: string
  width: number
  quality?: number
  root?: string
}

// strip any leading slashes
function normalizeSrc(src: string): string {
  return src[0] === '/' ? src.slice(1) : src
}

type Props = Parameters<typeof NextImage>[0] & {
  // https://cloudinary.com/documentation/media_optimizer_transformations#automatic_quality
  quality?:
    | 'auto'
    | 'auto:best'
    | 'auth:good'
    | 'auto:eco'
    | 'auto:low'
    | number
  gravity?:
    | 'auto'
    | 'center'
    | 'north_east'
    | 'north'
    | 'north_west'
    | 'west'
    | 'south_west'
    | 'south'
    | 'south_east'
    | 'east'
  crop?:
    | 'crop'
    | 'fill'
    | 'fill_pad'
    | 'fit'
    | 'lfill'
    | 'limit'
    | 'lpad'
    | 'mfit'
    | 'mpad'
    | 'pad'
    | 'scale'
    | 'thumb'
}

// https://cloudinary.com/documentation/transformation_reference
const Image = ({
  quality = 'auto',
  gravity = 'center',
  crop = 'limit',
  width: initialWidth,
  height: initialHeight,
  ...props
}: Props): ReturnType<typeof NextImage> => {
  const aspectRatio: number | null =
    typeof initialWidth === 'number' && typeof initialHeight === 'number'
      ? initialWidth / initialHeight
      : null
  const cloudinaryLoader = useCallback(
    ({ src, width }: ImageLoaderProps): string => {
      const height =
        gravity === 'auto' && aspectRatio ? width / aspectRatio : null
      const params = compact([
        'f_auto',
        'c_' + crop,
        'g_' + gravity,
        'w_' + width,
        height ? 'h_' + height : '',
        'q_' + (quality || 'auto')
      ])
      if (
        gravity === 'auto' &&
        !['fill', 'lfill', 'fill_pad', 'thumb', 'crop'].includes(crop)
      ) {
        console.error(
          'Automatic cropping is supported for the fill, lfill, fill_pad, thumb and crop modes.'
        )
      }
      const paramsString = `tx=${params.join(',')}&resource_type=image`
      const normalizedSrc = normalizeSrc(src)
      const parsedUrl = new URL(src)
      const joiner = parsedUrl.search && parsedUrl.search.length ? '&' : '?'
      return `${CLOUDINARY_HOST}/${normalizedSrc}${joiner}${paramsString}`
    },
    [crop, quality, gravity, aspectRatio]
  )

  let imageSrc: string | null = null
  // Next image accepts different types of imports
  if (!props.src) {
    imageSrc = null
  } else if (typeof props.src === 'string') {
    imageSrc = props.src
  } else if (typeof props.src.default !== 'undefined') {
    imageSrc = props.src.default.src
  } else if (typeof props.src.src !== 'undefined') {
    imageSrc = props.src.src
  }
  // disable optimization for dev environment and images present in source code
  const isNextImage = imageSrc?.includes('_next/static') || process.env.NODE_ENV === 'development'
  return (
    <NextImage
            {...props}
            height={initialHeight}
            loader={isNextImage ? undefined : cloudinaryLoader}
            width={initialWidth}
    />
  )
}

export default Image

Popular posts from this blog

Next.js: restrict pages to authenticated users

HTTP server in Ruby 3 - Fibers & Ractors