Generating Custom Image and Video Loading Placeholders in Next.js

Apr 2024

Ideally, a website should never show an empty space when a video or image is loading. Showing a placeholder that gives the user an idea of what's coming is a much better option. This generally allows us to serve high-quality images and videos without sacrificing loading aesthetics. One way to do this is to use Data URLs.

Data URLs are URLs prefixed with the data: scheme that usually encode some media data, like svgs or images, as a string. To be specific, they are encoded in Base64 which is just a way of encoding binary data to send media over systems that are primarily designed to handle text data (like HTML).

This allows us to embed small media inside HTML documents without the need for the server to call an additional HTTP request for the media, making it perfect for low-res loading placeholders that we want to load in sync with the HTML.

In Next.js this is easy to set up with the built-in Image component. Using it we can specify a placeholder="blur" attribute and a blurDataUrl to use while the image is loading.

<Image
  src="..."
  placeholder="blur"
  blurDataURL="data:image/svg+xml;base64 ..."
>

And for videos we can use this blurDataUrl as the poster attribute.

<video src="..." poster="data:image/svg+xml;base64 ..." />

An example looks like this:

Placeholder
Full Res

In Next.js, if you statically import an image and specify placeholder="blur" the blurDataURL property will be automatically generated for you. You can read more about the placeholder properity, but in short, that would look like this:

import Image from "next/image";
import mountains from "/mountains.jpg";

const PlaceholderBlur = () => (
  <Image alt="Mountains" src={mountains} placeholder="blur" />
);

This is great for static images, but what if you want to generate placeholders for videos, take more control over the placeholder quality, or just don't want to statically import every image? We can write a script to generate these placeholders for us.

Creating Custom DataURLs

To do this and maintain full control over the placeholder qualities let's write a simple Bun script to generate them. For flexibility, let's write it to handle either a folder of images or an individual file. To do this, we can use --file or --folder flags to choose a path for either. Let's also add control for adjusting the placeholder resolution. To do that, let's use a --max flag to specify the maximum width or height of the placeholder.

Now, let's start by processing those command line arguments.

const argsMap = new Map();
const args = process.argv.slice(2);

for (let i = 0; i < args.length; i++) {
  if (args[i].startsWith("--")) {
    argsMap.set(args[i], args[++i]);
  }
}

const filePath = argsMap.get("--file");
const directoryPath = argsMap.get("--dir") || "./public/images";
const max = parseInt(argsMap.get("--max"), 10) || 20;

Next, let's write a function to read a directory of images. I'm usually only interested in images with the .jpg or .jpeg extension, but you can modify this to suit your needs.

import fs from "fs";
import path from "path";

async function processDirectory(directory: string) {
  if (filePath) {
    throw new Error("Cannot use --file and --dir together");
  }

  const items = fs.readdirSync(directory, { withFileTypes: true });

  const calls = items.map(async (item) => {
    if (item.isDirectory()) {
      await processDirectory(path.join(directory, item.name));
    } else if (
      item.isFile() &&
      (item.name.endsWith(".jpg") || item.name.endsWith(".jpeg"))
    ) {
      // Process the image
    }
  });

  await Promise.all(calls);
}

Processing the Images

Now we need a function to process each image and generate a Base64 placeholder string. First, we can use the sharp library to resize the image to our desired max dimensions.

import sharp from "sharp";

const data: Record<string, string> = {};

async function processImage(imagePath: string) {
  const metaBefore = await sharp(imagePath).metadata();

  const { data: resizedImage } = await sharp(imagePath)
    .resize(max, max, { fit: "inside" })
    .toBuffer({ resolveWithObject: true });

  // ...
}

Then, we can then take the resized image data, convert it to Base64, and inject it into an SVG with a filter to blur the image. If you're unfamiliar with SVG filters, it might be helpul to read about them in the MDN Web Docs. Next, we can convert that SVG to Base64 and store it in a map with the image path as the key.

async function processImage(imagePath: string) {
  // Resizing  ...

  const base64str = resizedImage.toString("base64");

  const blurSvg = `
    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${metaBefore.width} ${metaBefore.height}'>
      <filter id="b">
        <feGaussianBlur
          in="SourceGraphic"
          result="blur"
          stdDeviation="0.2"
        ></feGaussianBlur>
        <feComponentTransfer>
          <feFuncA type="table" tableValues="1"></feFuncA>
        </feComponentTransfer>
      </filter>
      <image preserveAspectRatio='none' filter='url(#b)' x='0' y='0' height='100%' width='100%'
      href='data:image/jpeg;base64,${base64str}' />
    </svg>
  `;

  const toBase64 = (str: string) =>
    typeof window === "undefined"
      ? Buffer.from(str).toString("base64")
      : window.btoa(str);

  const key = filePath
    ? filePath
    : `/images/${path.relative(directoryPath, imagePath)}`;
  data[key] = `data:image/svg+xml;base64,${toBase64(blurSvg)}`;
}

Here, we're using a simple feGaussianBlur filter, which is just a type of blur, and a feComponentTransfer filter, which allows operations like contrast adjustment and color balancing. Here it's being used to make the blur spread more evenly around the edges of the image.

Feel free to play with the amount of blur and the size of the placeholder to get an effect you're looking for. You can also customize this script to generate different types of placeholders for different types of images. For example, you could create an svg gradient based on the average color of the image.

Finally, we can output the dataURLs to the console if we're running the script on a single file, or store the DataURLs in an a file if we're running the script on a directory.

if (filePath) {
  await processImage(filePath);

  console.log(data[filePath]);

  return;
} else {
  await processDirectory(directoryPath);

  fs.writeFileSync(
    `./app/_lib/${"dynamic-data-urls"}.ts`,
    `export const imageDataUrls = ${JSON.stringify(data)} as const`,
  );

  console.log("Dynamic data URLs file created", data);
}

Conclusion

Running the script for a single file would look like this:

bun generate-image-data-urls.ts --file ./public/images/image.jpg --max 20

And the output will look something like: data:image/svg+xml;base64,CiAgICA8c3ZnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgdmlld0JveD0nMCAwIDIwIDEyJz4KICAgICAgPGZpbHRlciBpZD0iYiI+CiAgICAgICAgPGZlR2F1c3NpYW5CbHVyCiAgICAgICAgICBpbj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgIHJlc3VsdD0iYmx1ciIKICAgICAgICAgIHN0ZERldmlhdGlvbj0iMC4yIgogICAgICAgID48L2ZlR2F1c3NpYW5CbHVyPgogICAgICAgIDxmZUNvbXBvbmVudFRyYW5zZmVyPgogICAgICAgICAgPGZlRnVuY0EgdHlwZT0idGFibGUiIHRhYmxlVmFsdWVzPSIxIj48L2ZlRnVuY0E+CiAgICAgICAgPC9mZUNvbXBvbmVudFRyYW5zZmVyPgogICAgICA8L2ZpbHRlcj4KICAgICAgPGltYWdlIHByZXNlcnZlQXNwZWN0UmF0aW89J25vbmUnIGZpbHRlcj0ndXJsKCNiKScgeD0nMCcgeT0nMCcgaGVpZ2h0PScxMDAlJyB3aWR0aD0nMTAwJScKICAgICAgaHJlZj0nZGF0YTppbWFnZS9qcGVnO2Jhc2U2NCwvOWovMndCREFBWUVCUVlGQkFZR0JRWUhCd1lJQ2hBS0Nna0pDaFFPRHd3UUZ4UVlHQmNVRmhZYUhTVWZHaHNqSEJZV0lDd2dJeVluS1NvcEdSOHRNQzBvTUNVb0tTai8yd0JEQVFjSEJ3b0lDaE1LQ2hNb0doWWFLQ2dvS0Nnb0tDZ29LQ2dvS0Nnb0tDZ29LQ2dvS0Nnb0tDZ29LQ2dvS0Nnb0tDZ29LQ2dvS0Nnb0tDZ29LQ2dvS0NqL3dBQVJDQUFNQUJRREFTSUFBaEVCQXhFQi84UUFHQUFBQWdNQUFBQUFBQUFBQUFBQUFBQUFBQU1CQkFqL3hBQWlFQUFCQXdNREJRQUFBQUFBQUFBQUFBQUJBQUlERVJJaEJCUWlNVUpSWWJIL3hBQVdBUUVCQVFBQUFBQUFBQUFBQUFBQUFBQUFBZ1AveEFBVUVRRUFBQUFBQUFBQUFBQUFBQUFBQUFBQS85b0FEQU1CQUFJUkF4RUFQd0RQZW1iQzVwM0RwQU8yMFZIdFBtaDBzTG5OSmt1cGloQitGSWpsSWdBdGJnOVZKbDVPNHR5UEMyUXF1d1VJa05YSVFmL1onIC8+CiAgICA8L3N2Zz4KICA=

For reference, the full script:

// generate-image-data-urls.ts

import fs from "fs";
import path from "path";
import sharp from "sharp";

async function getDynamicDataUrls() {
  const data: Record<string, string> = {};

  const argsMap = new Map();
  const args = process.argv.slice(2);

  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith("--")) {
      argsMap.set(args[i], args[++i]);
    }
  }

  const filePath = argsMap.get("--file");
  const directoryPath = argsMap.get("--dir") || "./public/images";
  const max = parseInt(argsMap.get("--max"), 10) || 14;

  async function processDirectory(directory: string) {
    if (filePath) {
      throw new Error("Cannot use --file and --dir together");
    }

    const items = fs.readdirSync(directory, { withFileTypes: true });

    const calls = items.map(async (item) => {
      if (item.isDirectory()) {
        await processDirectory(path.join(directory, item.name));
      } else if (
        item.isFile() &&
        (item.name.endsWith(".jpg") || item.name.endsWith(".jpeg"))
      ) {
        await processImage(path.join(directory, item.name));
      }
    });

    await Promise.all(calls);
  }

  async function processImage(imagePath: string) {
    const metaBefore = await sharp(imagePath).metadata();

    const { data: resizedImage } = await sharp(imagePath)
      .resize(max, max, { fit: "inside" })
      .toBuffer({ resolveWithObject: true });

    const base64str = resizedImage.toString("base64");

    const blurSvg = `
    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${metaBefore.width} ${metaBefore.height}'>
      <filter id="b">
        <feGaussianBlur
          in="SourceGraphic"
          result="blur"
          stdDeviation="0.2"
        ></feGaussianBlur>
        <feComponentTransfer>
          <feFuncA type="table" tableValues="1"></feFuncA>
        </feComponentTransfer>
      </filter>
      <image preserveAspectRatio='none' filter='url(#b)' x='0' y='0' height='100%' width='100%'
      href='data:image/jpeg;base64,${base64str}' />
    </svg>
  `;

    const toBase64 = (str: string) =>
      typeof window === "undefined"
        ? Buffer.from(str).toString("base64")
        : window.btoa(str);

    const key = filePath
      ? filePath
      : `/images/${path.relative(directoryPath, imagePath)}`;
    data[key] = `data:image/svg+xml;base64,${toBase64(blurSvg)}`;
  }

  if (filePath) {
    await processImage(filePath);

    console.log(data[filePath]);

    return;
  } else {
    await processDirectory(directoryPath);

    fs.writeFileSync(
      `./app/_lib/${"dynamic-data-urls"}.ts`,
      `export const imageDataUrls = ${JSON.stringify(data)} as const`,
    );

    console.log("Dynamic data URLs file created", data);
  }
}

getDynamicDataUrls();