Implementing Image Transformation Service with NextJS and imgproxy

Introduction

imgproxy is an efficient image processing and optimization service, featuring fast processing speeds, high security and low memory footprints because it is written in Go. The prominent advantages of imgproxy include the ability to resize, crop, compress and flexibly convert image formats (such as to WebP, AVIF) via URL. Notably, this service supports secure URL signing using HMAC encryption, which prevents DDoS attacks or unauthorized modifications of image size parameters from the client side.

Although the NextJS Image component already supports automatic image resizing, it presents several limitations if you choose it for large-scale deployment

  • Resized images stored in the cache of the .next folder only exist within a single NextJS server instance, making it difficult to share the cache when scaling up to multiple instances
  • After building the project, the cache data is lost, or if you find a way to persist these resized images, a large dataset will still consume too much disk volume within a single project
  • When there is a request to process data and fetch images, NextJS must handle both tasks simultaneously and the image resizing action still consumes a certain amount of CPU processing power

To resolve the above limitations, I will guide you through implementing an Image Transformation Service using a combination of the following components

  • MinIO: A storage service used exclusively for storage, which we will use to save images, thereby eliminating additional disk volume consumption within the NextJS project
  • imgproxy: As introduced above, this is an efficient image resizing service that replaces the resizing operation of NextJS, allowing the NextJS server to focus solely on logic and rendering in the future
  • Nginx: Used as a CDN for user access, supporting routing to the NextJS server and imgproxy, when a request for a resized image arrives, it will forward it to imgproxy for execution and the returned data will be cached to serve similar requests
  • NextJS: Continues to use the Image component normally, but now storage, resizing and caching issues are handled by other services

These services are chosen based on free and open-source criteria for easy self-hosting, if you deploy on a Cloud environment like AWS, you can replace them with similar services such as S3 for MinIO, Lambda for imgproxy and Cloudfront for Nginx

Prerequisites

  • This article provides a deployment method for large systems that need efficient image storage, management and optimization, if your project scale is not too large and only requires a single NextJS server instance to manage images, you can review my previous guide
  • In addition, I use MinIO as a free service that allows self-hosting to support storage, here I only guide you on using resources from MinIO rather than detailing the setup and storage process, you can review previous articles for more context
  • Before starting, you should have MinIO set up and upload the necessary images before proceeding

Detail

Create the file app/api/media-url/route.ts

import crypto from 'crypto'
import {NextResponse} from 'next/server'

let cachedKeyBin: Buffer | null = null
let cachedSaltBin: Buffer | null = null

const MEDIA_HOST = process.env.MEDIA_HOST!
const BUCKET_NAME = process.env.BUCKET_NAME!

function initializeCryptoBuffers(): {keyBin: Buffer; saltBin: Buffer} {
  if (cachedKeyBin && cachedSaltBin) {
    return {keyBin: cachedKeyBin, saltBin: cachedSaltBin}
  }

  const hexKey = process.env.IMGPROXY_KEY
  const hexSalt = process.env.IMGPROXY_SALT

  if (!hexKey || !hexSalt) {
    throw new Error(
      'CRITICAL: IMGPROXY_KEY or IMGPROXY_SALT environment variable is missing!'
    )
  }

  cachedKeyBin = Buffer.from(hexKey, 'hex')
  cachedSaltBin = Buffer.from(hexSalt, 'hex')

  console.log('SUCCESS: Imgproxy cryptographic buffers initialized and cached.')
  return {keyBin: cachedKeyBin, saltBin: cachedSaltBin}
}

function signUrl(path: string): string {
  const {keyBin, saltBin} = initializeCryptoBuffers()

  const hmac = crypto.createHmac('sha256', keyBin)
  hmac.update(saltBin)
  hmac.update(Buffer.from(path))

  const signature = hmac.digest().toString('base64url')
  return `${MEDIA_HOST}/${signature}${path}`
}

export function getSignedImgproxyUrl(
  src: string,
  width: number,
  quality: number = 100
): string {
  if (!src) return ''

  const cleanSrc = src.startsWith('/') ? src.slice(1) : src

  const plainSourceUrl =
    cleanSrc.startsWith('http://') || cleanSrc.startsWith('https://')
      ? cleanSrc
      : `s3://${BUCKET_NAME}/${cleanSrc}`

  const targetWidth = width > 0 ? width : 0
  const processingOptions = `/rs:fill:${targetWidth}:0/q:${quality}`
  const encodedSourceUrl = Buffer.from(plainSourceUrl).toString('base64url')
  const pathToSign = `${processingOptions}/${encodedSourceUrl}`

  return signUrl(pathToSign)
}

export async function GET(request: Request) {
  const {searchParams} = new URL(request.url)
  const src = searchParams.get('src')
  const width = searchParams.get('w') || '0'
  const quality = searchParams.get('q') || '100'

  if (!src) {
    return NextResponse.json(
      {error: 'Missing required query parameter: src'},
      {status: 400}
    )
  }

  const imgUrl = getSignedImgproxyUrl(src, +width, +quality)

  return NextResponse.redirect(imgUrl)
}
  • Implementing imgproxy without a security key poses risks to the system because hackers can perform DDoS attacks by resizing images in large quantities, therefore, an additional Secure Media Gateway in the form of an API Route Endpoint is needed to handle the creation and security of URLs for imgproxy
  • Utilizing two global cache variables (cachedKeyBin, cachedSaltBin) to store binary buffers after the first calculation (initializeCryptoBuffers) helps optimize performance, minimizing redundant processing overhead during application cold starts
  • The signUrl function is responsible for digitally signing the image processing path using the HMAC-SHA256 algorithm combined with the cached key and salt, generating a secure signature string to prevent clients from arbitrarily changing configuration parameters
  • The getSignedImgproxyUrl function receives the original image path (src), the desired image width (width) and the image quality (quality), it automatically analyzes and determines whether the image source belongs to an internal storage object on the MinIO system (s3://...) or a remote third-party URL link, after that, it configures image processing options such as resize (/rs:fill:...) and quality (/q:...), encodes this source link into base64url and calls the signUrl function to obtain the complete, securely signed path string
  • Finally, the GET function extracts the necessary parameters from the query string of the request, validates them and invokes the processing to generate a secure URL using the getSignedImgproxyUrl function, then, it executes a direct redirect command (NextResponse.redirect(imgUrl)) on the browser side to the secure image link without changing the main URL of the website
  • Note that these APIs can be fully invoked using tools like Postman, so you need to add security steps such as checking cookies or tokens beforehand to prevent the service from being overutilized by external parties

Create the file lib/imgproxy.loader.ts

export default function imgproxyClientLoader({
  src,
  width,
  quality,
}: {
  src: string
  width: number
  quality?: number
}) {
  const q = quality ? `&q=${quality}` : ''
  return `/api/media-url?src=${encodeURIComponent(src)}&w=${width}${q}`
}

This file defines a Custom Loader for the Image component running in the client environment, to maintain security and avoid exposing encryption logic (key and salt) to the client side, this loader forwards the image processing request by pointing directly to an internal NextJS API Route, which is /api/media-url.

Create the file next.config.ts to use the loader file above

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: 'standalone',
  images: {
    loaderFile: './lib/imgproxy.loader.ts',
  }
};

export default nextConfig;

Create the file app/image-transformation-service/types.ts

export interface Product {
  id: number
  title: string
  price: number
  thumbnail: string
}

export interface ProductListProps {
  products: Product[]
}

Create the file app/image-transformation-service/api.ts to mock fetching data for banners, categories and product images, note that these filenames must match what you have stored in MinIO

import {delay} from 'msw'

const CATEGORIES = [
  {id: 1, name: 'Electronics', img: 'cat-1.jpg'},
  {id: 2, name: 'Fashion', img: 'cat-2.jpg'},
  {id: 3, name: 'Home', img: 'cat-3.jpg'},
  {id: 4, name: 'Beauty', img: 'cat-4.jpg'},
]

const PRODUCTS = [
  {
    id: 1,
    title: 'Essence Mascara Lash Princess',
    price: 9.99,
    thumbnail: 'prod-1.webp',
  },
  {
    id: 2,
    title: 'Eyeshadow Palette with Mirror',
    price: 19.99,
    thumbnail: 'prod-2.webp',
  },
  {
    id: 3,
    title: 'Powder Canister',
    price: 14.99,
    thumbnail: 'prod-3.webp',
  },
  {
    id: 4,
    title: 'Red Lipstick',
    price: 12.99,
    thumbnail: 'prod-4.webp',
  },
  {
    id: 5,
    title: 'Red Nail Polish',
    price: 8.99,
    thumbnail: 'prod-5.webp',
  },
  {
    id: 6,
    title: 'Calvin Klein CK One',
    price: 49.99,
    thumbnail: 'prod-6.webp',
  },
  {
    id: 7,
    title: 'Chanel Coco Noir Eau De',
    price: 129.99,
    thumbnail: 'prod-7.webp',
  },
  {
    id: 8,
    title: "Dior J'adore",
    price: 89.99,
    thumbnail: 'prod-8.webp',
  },
]

export async function getData() {
  await delay(500)
  return {
    banner: {
      src: 'banner.jpeg',
      title: 'E-commerce Banner',
    },
    products: PRODUCTS,
    categories: CATEGORIES,
  }
}

Create the file app/image-transformation-service/ProductList.tsx

import Image from 'next/image'
import type {ProductListProps} from './types'

export default function ProductList({products}: ProductListProps) {
  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
      {products.map(product => (
        <div key={product.id} className="group border p-4">
          <div className="relative aspect-square">
            <Image
              src={product.thumbnail}
              alt={product.title}
              fill
              sizes="(max-width: 768px) 100vw, 25vw"
              loading="lazy"
            />
          </div>
          <h3>{product.title}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  )
}

Create the file app/image-transformation-service/page.tsx

import Image from 'next/image'
import {getData} from './api'
import ProductList from './ProductList'

export default async function HomePage() {
  const data = await getData()
  return (
    <main className="max-w-7xl mx-auto px-4 py-8">
      <section className="relative w-full h-[400px] mb-12 rounded-xl overflow-hidden">
        <Image
          src={data.banner.src}
          alt={data.banner.title}
          fill
          preload
          sizes="(min-width: 1280px) 1280px, 100vw"
          className="object-cover"
        />
        <div className="absolute inset-0 bg-black/20 flex items-center justify-center">
          <h1 className="text-white text-5xl font-bold">New Collection</h1>
        </div>
      </section>

      <section className="mb-12">
        <h2 className="text-2xl font-bold mb-6">Categories</h2>
        <div className="flex gap-6 overflow-x-auto pb-4 scrollbar-hide">
          {data.categories.map(cat => (
            <div key={cat.id} className="flex-shrink-0 text-center">
              <div className="relative w-24 h-24 mb-2">
                <Image
                  src={cat.img}
                  alt={cat.name}
                  fill
                  loading="eager"
                  sizes="96px"
                  className="rounded-full object-cover border-2 border-gray-100"
                />
              </div>
              <span className="text-sm font-medium">{cat.name}</span>
            </div>
          ))}
        </div>
      </section>

      <section>
        <h2 className="text-2xl font-bold mb-6">Featured Products</h2>
        <ProductList products={data.products} />
      </section>
    </main>
  )
}

The contents of page.tsx and ProductList.tsx are similar to the previous article, with changes only applied to the Image src

Create the .env file with the following values, you can alter these values according to your needs, including the AWS values because we are using MinIO so they can be set arbitrarily, as for the MEDIA_HOST field, please set the exact value pointing to the Nginx host on local or after deployment

MINIO_ROOT_USER = <MINIO_ROOT_USER>
MINIO_ROOT_PASSWORD = <MINIO_ROOT_PASSWORD>
BUCKET_NAME = <BUCKET_NAME>
MEDIA_HOST = http://localhost/media

IMGPROXY_KEY = <IMGPROXY_KEY>
IMGPROXY_SALT = <IMGPROXY_SALT>

AWS_ACCESS_KEY_ID = <AWS_ACCESS_KEY_ID>
AWS_SECRET_ACCESS_KEY = <AWS_SECRET_ACCESS_KEY>

Create the file nginx.conf

user nginx;
worker_processes auto;

events { worker_connections 1024; }

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=imgproxy_cache:10m max_size=10g inactive=30d use_temp_path=off;

    server {
        listen 80;
        server_name localhost;

        location /media/ {
            proxy_cache imgproxy_cache;
            proxy_cache_key "$scheme$request_method$host$request_uri";
            proxy_cache_valid 200 302 30d;
            proxy_cache_lock on;
            
            add_header X-Cache-Status $upstream_cache_status;
            add_header Cache-Control "public, max-page=2592000, immutable"; rewrite ^/media/(.*)$ /$1 break; proxy_pass http://imgproxy:8080; proxy_set_header Host $host; } location / { proxy_pass http://nextjs:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } }

This is the Nginx configuration to implement Reverse Proxy and Caching

  • The proxy_cache_path block configures cache storage inside var/cache/nginx, setting a maximum size of 10GB and a duration of 30 days

  • The location /media/ block handles image URLs

  • If an image has not been resized yet, it forwards the request to proxy_pass http://imgproxy:8080

  • If it is already available, it serves it from the cache

  • The rewrite directive removes the /media/ prefix from the initial image URL

  • Adds the X-Cache-Status header to check whether the cache status is a HIT or a MISS

  • In the location / block, Nginx directly forwards other requests such as APIs and static resources like JS and CSS to the NextJS server at http://nextjs:3000

Create the file docker-compose.yml

networks:
  app_net:
    driver: bridge

volumes:
  minio_data:
  nginx_cache:

services:
  nextjs:
    build:
      context: .
    container_name: nextjs
    networks:
      - app_net
    environment:
      IMGPROXY_KEY: ${IMGPROXY_KEY}
      IMGPROXY_SALT: ${IMGPROXY_SALT}
      MINIO_ENDPOINT: "http://minio:9000"
      MEDIA_HOST: ${MEDIA_HOST}
      BUCKET_NAME: ${BUCKET_NAME}

  minio:
    image: minio/minio:latest
    container_name: media_storage
    volumes:
      - minio_data:/data
    ports:
      - "9001:9001"
    networks:
      - app_net
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}

  imgproxy:
    image: darthsim/imgproxy:latest
    container_name: media_processor
    depends_on:
      - minio
    networks:
      - app_net
    environment:
      IMGPROXY_KEY: ${IMGPROXY_KEY}
      IMGPROXY_SALT: ${IMGPROXY_SALT}
      IMGPROXY_USE_S3: "true"
      IMGPROXY_S3_ENDPOINT: "http://minio:9000"
      IMGPROXY_S3_REGION: "us-east-1"
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      IMGPROXY_S3_FORCE_PATH_STYLE: "true"
      IMGPROXY_ENFORCE_WEBP: "true"

  nginx:
    image: nginx:alpine
    container_name: global_gateway
    depends_on:
      - nextjs
      - imgproxy
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - nginx_cache:/var/cache/nginx
    networks:
      - app_net
    restart: always

This is where the required services are defined

  • nextjs: Starts on port 3000, here I do not expose the port publicly because we will access it via Nginx, which will then forward requests within the internal network to NextJS
  • minio: A storage service, port 9000 (only private) allows imgproxy to connect via API and public port 9001 provides a console for you to verify uploaded files
  • imgproxy: An image processing service that converts images into WebP format to optimize download sizes, the config IMGPROXY_USE_S3: "true" establishes its compatibility with standard AWS S3 Clients
  • nginx: Acts as a Reverse Proxy, exposing port 80 for your access

Before continuing, you need a Dockerfile to build the docker image for the NextJS project, the content of this file remains unchanged, so you can refer back to the previous article where I covered it

Afterward, spin up the servers

docker compose up -d


The outcome is as follows



This is the request when Nginx already has the cache; you can see the x-cache-status header field is HIT. 


And this is the request when the browser already has the cache—it will fetch the image from the disk cache instead of making a call to Nginx.



You can access the image URL directly, but if you perform manual modifications to the image size, an error will be triggered because it has not been processed and encrypted with the key and salt of imgproxy


Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Sitemap

React Practice Series

Monitoring with cAdvisor, Prometheus and Grafana on Docker

DevOps Practice Series

A Handy Guide to Using Dynamic Import in JavaScript

Using Kafka with Docker and NodeJS