Auto-generating Next.js Favicons from a Raster Image

When it came time to create the favicons for this site, I noticed that the template I was basing it on used someone's headshot as the favicon. I thought that was a good idea, and I wanted to do the same using my headshot. I also wanted to make sure that I created all of the different favicon sizes and formats needed for various devices and platforms. I could have just created all of the images using a favicon generator website and called it a day, but I wanted to automate the process in case I ever decided to change the favicon in the future.

When I looked into automation options, I kept finding resources for working with vector/SVG images, but I didn't find any content for working with raster images (like PNG or JPEG). Since my headshot is a raster image, I needed to find a way to generate the favicons from that format.

I was able to stitch together a solution based on the favicons npm package and a simple Node.js script.

The sample scripts for the favicons package showed the basics for generating the favicons, but it generated way more image variants than I felt I needed. Here's what I trimmed it's configuration down to:

import { favicons, type FaviconImage, type FaviconOptions } from "favicons";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs/promises";
import { assert } from "console";

// emulate __dirname in ES module scope
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const configuration: FaviconOptions = {
  path: "/", // Path for overriding default icons path. `string`
  background: "#fff", // Background colour for flattened icons. `string`
  appleStatusBarStyle: "black-translucent", // Style for Apple status bar: "black-translucent", "default", "black". `string`
  display: "standalone", // Preferred display mode: "fullscreen", "standalone", "minimal-ui" or "browser". `string`
  orientation: "any", // Default orientation: "any", "natural", "portrait" or "landscape". `string`
  scope: "/", // set of URLs that the browser considers within your app
  pixel_art: false, // Keeps pixels "sharp" when scaling up, for pixel art.  Only supported in offline mode.
  icons: {
    // Platform Options:
    // - offset - offset in percentage
    // - background:
    //   * false - use default
    //   * true - force use default, e.g. set background for Android icons
    //   * color - set background for the specified icons
    //
    appleStartup: false, // Create Apple startup images. `boolean` or `{ offset, background }` or an array of sources
    windows: false, // Create Windows 8 tile icons. `boolean` or `{ offset, background }` or an array of sources
    yandex: false, // Create Yandex browser icon. `boolean` or `{ offset, background }` or an array of sources
    android: [
      "android-chrome-144x144.png",
      "android-chrome-192x192.png",
      "android-chrome-256x256.png",
      "android-chrome-36x36.png",
      "android-chrome-384x384.png",
      "android-chrome-48x48.png",
      "android-chrome-512x512.png",
      "android-chrome-72x72.png",
      "android-chrome-96x96.png"
    ],
    appleIcon: [
      "apple-touch-icon-1024x1024.png",
      "apple-touch-icon-114x114.png",
      "apple-touch-icon-120x120.png",
      "apple-touch-icon-144x144.png",
      "apple-touch-icon-152x152.png",
      "apple-touch-icon-167x167.png",
      "apple-touch-icon-180x180.png",
      "apple-touch-icon-57x57.png",
      "apple-touch-icon-60x60.png",
      "apple-touch-icon-72x72.png",
      "apple-touch-icon-76x76.png",
      "apple-touch-icon-precomposed.png",
      "apple-touch-icon.png"
    ],
    favicons: [
      "favicon.ico"
    ],
  },
  output: {
    images: true,
    files: false,
    html: false,
  }
};

const appDir = path.join(__dirname, "..", "..", "src", "app");
const dest = path.join(__dirname, "generated");
const source = path.join(__dirname, "..", "..", "src", "images", "avatar.jpg");

const response = await favicons(source, configuration);
await fs.mkdir(dest, { recursive: true });

That gave me the images that I wanted, but they weren't named or located where I needed them in order to be picked up by Next.js's support for favicons.

I needed the images to be located at the top of the src/app directory, and I needed them to be named:

  • apple-iconX.png where X starts at 1 and increments for each image. The first image should be the default size, and then the rest of the images should be in descending order by size.
  • favicon.ico
  • iconX.png where X follows the same pattern that's used for the Apple icons.

So I needed to rename the apple-touch-icon-*.png files to apple-iconX.png, and the android-chrome-*.png files to iconX.png, and move them all to the src/app directory.

The first step was to collect the images and their metadata into a objects that I could work with a little bit easier than the response object.

interface FaviconAndMetadata {
  icon: FaviconImage;
  size?: number;
  targetPrefix?: string;
  index?: number;
}

const imagesAndMetadata: FaviconAndMetadata[] = response.images
  .filter((icon) => icon.name !== "apple-touch-icon-precomposed.png")
  .map((icon) => {
    let size: number | undefined = undefined;
    const sizeMatch = icon.name.match(/-(\d+)x(\d+)\.png$/);
    if (sizeMatch) {
      size = parseInt(sizeMatch[1], 10);
    }

    let targetPrefix: string | undefined = undefined;
    if (icon.name.startsWith("apple-touch-icon")) {
      targetPrefix = "apple-icon";
    } else if (icon.name.startsWith("android-chrome")) {
      targetPrefix = "icon";
    }

    return {
      icon,
      size,
      targetPrefix,
    };
  });

That gave me an array of images that I could organize in later steps. It also filters out the apple-touch-icon-precomposed.png image since I didn't need that one. It had the same dimensions as one of the other Apple icons, so it was redundant.

Now I had the icon, it's "size" as a an integer of the number of pixels on one side of the square image, and a "targetPrefix" that indicated whether it was an Apple icon or an Android icon.

Next, I needed to sort the images so that I could assign the correct index numbers when renaming them. The default Apple icon (which doesn't have a size in its name) should be first, followed by the rest of the Apple icons in descending order by size, followed by the Android icons in descending order by size.

const sortedImagesAndMetadata = imagesAndMetadata
  .sort((a, b) => {
    function compareBySize(a: FaviconAndMetadata, b: FaviconAndMetadata): number {
      if (a.size && b.size) {
        return b.size - a.size;
      } else if (b.size) {
        return -1;
      } else if (a.size) {
        return 1;
      } else {
        return 0;
      }
    }

    if (a.targetPrefix && b.targetPrefix) {
      if (a.targetPrefix === b.targetPrefix) {
        return compareBySize(a, b);
      }
      return a.targetPrefix.localeCompare(b.targetPrefix);
    } else if (a.targetPrefix) {
      return -1;
    } else if (b.targetPrefix) {
      return 1;
    } else {
      return compareBySize(a, b);
    }
  });

With the images sorted, I could now assign the correct index numbers for renaming.

const groupedIcons = Object
  .groupBy(sortedImagesAndMetadata, (item: FaviconAndMetadata) =>
    item.targetPrefix !== undefined ? item.targetPrefix : "other");

Object.values(groupedIcons).forEach((group) => {
  if (!group) {
    return;
  }

  group.forEach((item, index) => {
    item.index = index + 1;
  });
});

With all that done, all that was left was to write the image data to the correct locations. I chose to write the images to the directory where the script is located, while also writing them into the correct location in the src/app directory.

And I did the work async using a couple calls to Promise.all to try and speed things up a bit.

await Promise.all(
  sortedImagesAndMetadata.map(
    async (image) => {
      const destinations = [
        path.join(dest, image.icon.name)
      ];

      if (!image.targetPrefix) {
        destinations.push(path.join(appDir, image.icon.name));
      } else {
        assert(image.index !== undefined);
        destinations.push(
          path.join(appDir, `${image.targetPrefix}${image.index}.png`)
        );
      }

      await Promise.all(
        destinations.map((destination) =>
          fs.writeFile(destination, image.icon.contents)
        )
      );
    },
  ),
);

The final touch was to add all of the generated images to .gitgnore and make the script run on build.

In .gitignore:

# Icons generated by pnpm build:favicons
/src/app/apple-icon*.png
/src/app/icon*.png
/src/app/favicon.ico

In scripts/generate-favicons/.gitignore:

generated/

(I could probably get rid of that second .gitignore file now that I think about it...)

In package.json:

{
  "scripts": {
    "predev": "pnpm run build:favicons",
    "prebuild": "pnpm run build:favicons",
    "build:favicons": "ts-node --esm ./scripts/generate-favicons/index.ts",
  }
}

And that was it. You can view the full script, which I used to copy and paste the code snippets above, on GitHub.

In the future, I plan to detect if the images need to be generated. Right now the script runs every time I build or run the dev server, even if the source image hasn't changed. It doesn't take a super long time, but it would be nice to skip it when it's not needed.