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
signUrlfunction 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
getSignedImgproxyUrlfunction 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 thesignUrlfunction to obtain the complete, securely signed path string - Finally, the
GETfunction extracts the necessary parameters from the query string of the request, validates them and invokes the processing to generate a secure URL using thegetSignedImgproxyUrlfunction, 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_pathblock configures cache storage insidevar/cache/nginx, setting a maximum size of 10GB and a duration of 30 daysThe
location /media/block handles image URLsIf an image has not been resized yet, it forwards the request to
proxy_pass http://imgproxy:8080If it is already available, it serves it from the cache
The
rewritedirective removes the/media/prefix from the initial image URLAdds the
X-Cache-Statusheader to check whether the cache status is aHITor aMISSIn the
location /block, Nginx directly forwards other requests such as APIs and static resources like JS and CSS to the NextJS server athttp://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 NextJSminio: A storage service, port9000(only private) allowsimgproxyto connect via API and public port9001provides a console for you to verify uploaded filesimgproxy: An image processing service that converts images into WebP format to optimize download sizes, the configIMGPROXY_USE_S3: "true"establishes its compatibility with standard AWS S3 Clientsnginx: Acts as a Reverse Proxy, exposing port80for 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.
Happy coding!
Comments
Post a Comment