Optimize images for a static website NextJS React

November 1, 2022

4 min read

Optimize images for a static website NextJS React
Watch on YouTube

Let me show a generic approach for rendering optimized images in React.

Recently I had to migrate from Gatsby to NextJS. It was relatively straightforward, except for the images. You see, Gatsby supports image optimization by converting them to a better format like .webp and generating a few variations of an image with different sizes. It worked well for my projects since it didn't have many images, so the build process was relatively fast.

But NextJS's Image component requires a server. It won't optimize images in the build stage. Since I didn't want to run servers for my simple static website, I decided to implement a simple agnostic approach that would work for any React setup. You can find both the source code and the demo in the description.

import { useWasIt } from "lib/shared/hooks/useWasIt"
import { RefObject, useRef } from "react"
import { useIntersection } from "react-use"

interface RenderParams<T extends HTMLElement> {
  wasIntersected: boolean
  isIntersecting: boolean
  ref: RefObject<T>
}

interface Props<T extends HTMLElement> {
  rootMargin?: string
  render: (params: RenderParams<T>) => void
}

export function IntersectionAware<T extends HTMLElement>({
  render,
  rootMargin = "1000px",
}: Props<T>) {
  const ref = useRef<T>(null)
  const intersection = useIntersection(ref, {
    root: null,
    rootMargin,
    threshold: 0,
  })

  const isIntersecting = !!intersection?.isIntersecting
  const wasIntersected = useWasIt(intersection?.isIntersecting, true)

  return <>{render({ ref, isIntersecting, wasIntersected })}</>
}

We don't want to load a hidden image down the page, so we leverage Intersection API and the useIntersection hook. The component takes the rootMargin parameter and the render function that will provide ref and wasIntersected. In this example, we'll start rendering an image only when it's 1000 pixels from being visible on the page.

import React, { forwardRef, ReactNode } from "react"
import styled from "styled-components"
import { Center } from "../Center"
import { ImageIcon } from "../icons/ImageIcon"

interface Props {
  width?: React.CSSProperties["width"]
  height?: React.CSSProperties["height"]
  className?: string
  children?: ReactNode
}

export const Container = styled.div`
  position: relative;
  overflow: hidden;

  border-radius: 16px;
  background: ${({ theme }) => theme.colors.backgroundGlass.toCssValue()};
  box-shadow: ${({ theme }) => theme.shadows.small};
`

const ImageIconWr = styled(Center)`
  z-index: -1;

  position: absolute;
  font-size: 20px;
  background: ${({ theme }) => theme.colors.textSupporting3.toCssValue()};
`

export const ImageHolder = forwardRef(function ImageHolderInner(
  { width, height, children }: Props,
  ref: React.Ref<HTMLDivElement> | null
) {
  return (
    <Container style={{ width, height }} ref={ref}>
      <ImageIconWr>
        <ImageIcon />
      </ImageIconWr>
      {children}
    </Container>
  )
})

Then, we render ImageHolder, a regular div container that could have a fixed width and height, and it will show a gray box with a centered icon while we load the image. The idea is to have a placeholder and not have a jumping page content that leads to a lover cumulative layout shift and therefore lovers our SEO score.

Finally, we render the SafeImage component. It takes to source, fallback, and render props to try to load an image and show a fallback or nothing in case of failure to fetch it.

import { ReactNode } from "react"
import { useBoolean } from "lib/shared/hooks/useBoolean"

interface RenderParams {
  src: string
  onError: () => void
}

interface Props {
  src?: string
  fallback?: ReactNode
  render: (params: RenderParams) => void
}

export const SafeImage = ({ fallback = null, src, render }: Props) => {
  const [isFailedToLoad, { set: failedToLoad }] = useBoolean(false)

  return (
    <>
      {isFailedToLoad || !src
        ? fallback
        : render({ onError: failedToLoad, src })}
    </>
  )
}

We want to use an image format optimized for the web. The most popular one is .webp. Here we have a bash script that would go over every image with a .jpeg or .png extension and convert it to a better format using the cwebp command line tool. The second step is optional, and it might not make sense for your project, but here we can have all the images with the same max-width, so it is safe for me to go and resize every image while keeping the aspect ratio using the same cwebp utility.

for file in $(find ./public/images -name '*.jpeg' -or -name '*.jpg' -or -name '*.png');
do
  cwebp -q 80 "$file" -o "${file%.*}.webp"
  rm "$file"
done

for file in $(find ./public/images -name '*.webp');
do
  cwebp -resize 400 0 "$file" -o "$file"
done

This bash script is just a simple implementation of build-time image optimization. Most likely, you don't want to delete all your high-resolution images during the optimization process, and you would like to have a different max-width based on image size.