How to dynamically create responsive images with NextJS

May 2020

3364 views

Working demo

Final code repository

Motivation

There are a couple of existing solutions out there for our image transformation needs. Cloudinary for example. Cloudinary may work well for low-traffic projects, but not for projects gaining a good amount of traffic. Expenses could soon rise as our app quickly gains more traffic.

Our cost-effective solution

Our proposed way of creating a severless image transformation API will use the following technologies:

  • NextJS
  • Vercel (where we deploy our NextJS app)
  • Unsplash (source of our test images)

Step 1: Initialize our app

Create our base NextJS app

npm init next-app

Install dependencies

# sharp - image processing library
# isomorphic-unfetch - fetch API
npm install sharp isomorphic-unfetch --save

Step 2: Setting up our image-transformation API route

# create our image function directory/file
mkdir pages/api/image
# create our file
touch pages/api/image/[...slug].js

We have just created our API route.

/api/image/[...slug]

This is how NextJS handles Dynamic API routes. You can read more here.

Step 3: Building our image-transformation processing

Open the file we have recently created and copy this code.

// pages/api/image/[...slug].js

export default async (request, response) => {
  const {
    query: { slug },
  } = request

  const [options, imageUrl] = slug

  const decodedUrl = decodeURIComponent(imageUrl)

  response.statusCode = 200
  response.json({ options, imageUrl: decodedUrl })
}

Now try to access this path,

/api/image/w=800&h=800/https%3A%2F%2Fsource.unsplash.com%2F1Shk_PkNkNw%2F1600x900

You should be able to have a response like this:

first reponse example

The URL that you have just accessed is our final API path.

// final API path
/api/image/{options}/{imageUrl}

// The options parameter will accept our desired image dimensions,
// eg. width and height
- options: `width=800&height=800`

// imageUrl is the sourceUrl of the image we want to transform.
- imageUrl: `https://source.unsplash.com/1Shk_PkNkNw/1600x900`

Step 4: Parsing our options parameters

On our api/image/[...slug].js file, copy the code below to parse our options parameter. Parsing our options parameter only means we are formatting the data to a shape we need.

// Convert option in a string format to a key-value pair
// key=value   { [key]: value }
// key         { [key]: true }
const optionToKeyVal = (option) =>
  ((split) =>
    split.length > 0
      ? { [split[0]]: split.length > 1 ? split[1] : true }
      : undefined)(option.split('='))

// Parse options string and return options object
const parseOptions = (options) => {
  return options
    .split('&')
    .reduce((acc, option) => ({ ...acc, ...optionToKeyVal(option) }), {})
}

export default async (request, response) => {
  const {
    query: { slug },
  } = request

  const [options, imageUrl] = slug

  const decodedUrl = decodeURIComponent(imageUrl)
  const parsedOptions = parseOptions(options)

  response.statusCode = 200
  response.json({ options, parsedOptions, imageUrl: decodedUrl })
}

Accessing our API now should have a result like this,

{
  "options": "w=800&h=800",
  // our parseOptions function will just transform our options to this format,
  "parsedOptions": { "w": "800", "h": "800" },
  "imageUrl": "https://source.unsplash.com/1Shk_PkNkNw/1600x900"
}

Step 5: Processing our image

This is the part where we use sharp to process our image to have the dimensions we want it to have.

In this example we are just resizing our image. You can read more at sharp docs.

// api/image/[...slug].js
import sharp from "sharp";
import fetch from "isomorphic-unfetch";

...

export default async (request, response) => {
  ...

  // fetch our image stream
  const readStream = await fetch(decodedUrl)

  // handling our image processing using sharp
  const transform = sharp()
    .resize(
      parsedOptions.w ? Number(parsedOptions.w) : undefined,
      parsedOptions.h ? Number(parsedOptions.h) : undefined,
      { fit: 'cover' }
    )
    .jpeg({ progressive: true })

  response.statusCode = 200
  response.json({ options, parsedOptions, imageUrl: decodedUrl })
}

Step 6: Response headers

Now that our transform function is ready, it is now time to build response headers to be sent back to the client.

export default async (request, response) => {
  ...

  response.statusCode = 200
  // response.json({ options, parsedOptions, imageUrl: decodedUrl });

  // setting our cache duration
  const cacheMaxAge = 30 * 60 // 30 minutes
  response.setHeader('cache-control', `public, max-age=${cacheMaxAge}`)

  // setting our "Content-Type" as an image file
  response.setHeader('Content-Type', readStream.headers.get('content-type'))

  // final response
  readStream.body.pipe(transform).pipe(response)
}

Wrapping up

Now if we access our image API we should see an image with 800x800 dimension

/api/image/w=800&h=800/https%3A%2F%2Fsource.unsplash.com%2F1Shk_PkNkNw%2F1600x900

reponse example burger

Final code

import sharp from 'sharp'
import fetch from 'isomorphic-unfetch'

// Convert option in a string format to a key-value pair
// key=value   { [key]: value }
// key         { [key]: true }
const optionToKeyVal = (option) =>
  ((split) =>
    split.length > 0
      ? { [split[0]]: split.length > 1 ? split[1] : true }
      : undefined)(option.split('='))

// Parse options string and return options object
const parseOptions = (options) => {
  return options
    .split('&')
    .reduce((acc, option) => ({ ...acc, ...optionToKeyVal(option) }), {})
}

export default async (request, response) => {
  const {
    query: { slug },
  } = request

  const [options, imageUrl] = slug

  const decodedUrl = decodeURIComponent(imageUrl)

  // fetch our image stream
  const readStream = await fetch(decodedUrl)

  // handling our image processing using sharp
  const parsedOptions = parseOptions(options)
  const transform = sharp()
    .resize(
      parsedOptions.w ? Number(parsedOptions.w) : undefined,
      parsedOptions.h ? Number(parsedOptions.h) : undefined,
      { fit: 'cover' }
    )
    .jpeg({ progressive: true })

  response.statusCode = 200

  // setting our cache duration
  const cacheMaxAge = 30 * 60 // 30 minutes
  response.setHeader('cache-control', `public, max-age=${cacheMaxAge}`)

  // setting our "Content-Type" as an image file
  response.setHeader('Content-Type', readStream.headers.get('content-type'))

  // final response
  readStream.body.pipe(transform).pipe(response)
}

Working demo

Final code repository

Final thoughts

This image transformation API will in no way replace Cloudinary's massive feature sets. It is however a good way to start if you are looking for alternatives.

Performance wise, I say it is pretty good when deployed to Vercel. I'm not an expert and I'm not entirely sure how to measure and compare performance versus other options out there.

Cold-starts are not that bad, but when the cache is hit it really performs well.

This is roughly a draft of building a robust image processing API. In the end what we are going for is to reduce the expense of hobbyists for their personal projects.