DEV Community

fanthus
fanthus

Posted on

How I Built a Browser-Based Photo Pixelation Tool with Next.js

I wanted a quick way to hide a face, license plate, or line of private text before sharing an image. So I built Pixelate Photo, a small Next.js tool that processes images locally.

The product itself is simple:

  1. Open an image.
  2. Pixelate the whole image or select specific areas.
  3. Adjust the pixel size.
  4. Download the result.

The interesting part was making the preview, selection tools, and exported image all use the same rendering logic.

In this article, I’ll focus on that pipeline.

The architecture

The application uses:

  • Next.js with the App Router
  • React and TypeScript
  • The Canvas API for image processing
  • ImageBitmap for decoding images
  • A React reducer for editor history

The page content can stay server-rendered, but the editor needs state, pointer events, and browser APIs such as createImageBitmap() and <canvas>.

That makes the editor a Client Component:

"use client"

import { useReducer, useRef } from "react"

export default function ImagePixelator() {
  // Interactive editor logic lives here.
}
Enter fullscreen mode Exit fullscreen mode

I kept the "use client" boundary around the editor instead of turning the entire page into a Client Component. The landing-page content, metadata, and other static sections do not need client-side JavaScript.

The important architectural decision was to keep the actual image-processing code outside the React components:

components/editor/
  ImagePixelator.tsx
  ImageCanvas.tsx

lib/canvas/
  load-image.ts
  pixelate.ts
  export-image.ts
  types.ts
Enter fullscreen mode Exit fullscreen mode

React manages interaction. The canvas modules manage pixels.

That separation became especially useful when I needed the preview and downloaded image to produce the same result.

Step 1: Load the image into an ImageBitmap

The browser gives us a File after the user selects or pastes an image. Instead of creating an <img> element and waiting for it to load, I decode the file with createImageBitmap():

export async function loadImageFile(file: File) {
  const allowedTypes = new Set([
    "image/jpeg",
    "image/png",
    "image/webp",
  ])

  if (!allowedTypes.has(file.type)) {
    throw new Error("Use a JPG, PNG, or WebP image.")
  }

  const bitmap = await createImageBitmap(file, {
    imageOrientation: "from-image",
  })

  if (bitmap.width < 1 || bitmap.height < 1) {
    bitmap.close()
    throw new Error("Could not read this image.")
  }

  return {
    bitmap,
    width: bitmap.width,
    height: bitmap.height,
  }
}
Enter fullscreen mode Exit fullscreen mode

ImageBitmap works well here because it can be drawn directly onto a canvas:

context.drawImage(bitmap, 0, 0)
Enter fullscreen mode Exit fullscreen mode

The image stays in browser memory. There is no upload endpoint and no server-side image-processing job.

That is not just a privacy feature. It also simplifies the architecture: no storage, upload progress, cleanup job, or image-processing service is needed.

Step 2: Create the pixelation effect

The pixelation algorithm is smaller than it might seem.

The basic trick is:

  1. Draw the source image onto a very small canvas.
  2. Scale that small canvas back up.
  3. Disable image smoothing while scaling up.

Here is a simplified version:

function renderMosaic(
  source: CanvasImageSource,
  sourceWidth: number,
  sourceHeight: number,
  outputWidth: number,
  outputHeight: number,
  pixelSize: number,
) {
  const blocksX = Math.max(
    1,
    Math.round(sourceWidth / pixelSize),
  )

  const blocksY = Math.max(
    1,
    Math.round(sourceHeight / pixelSize),
  )

  const smallCanvas = document.createElement("canvas")
  smallCanvas.width = blocksX
  smallCanvas.height = blocksY

  const smallContext = smallCanvas.getContext("2d")
  if (!smallContext) {
    throw new Error("Could not create canvas context.")
  }

  smallContext.imageSmoothingEnabled = true
  smallContext.drawImage(
    source,
    0,
    0,
    sourceWidth,
    sourceHeight,
    0,
    0,
    blocksX,
    blocksY,
  )

  const outputCanvas = document.createElement("canvas")
  outputCanvas.width = outputWidth
  outputCanvas.height = outputHeight

  const outputContext = outputCanvas.getContext("2d")
  if (!outputContext) {
    throw new Error("Could not create canvas context.")
  }

  outputContext.imageSmoothingEnabled = false
  outputContext.drawImage(
    smallCanvas,
    0,
    0,
    blocksX,
    blocksY,
    0,
    0,
    outputWidth,
    outputHeight,
  )

  return outputCanvas
}
Enter fullscreen mode Exit fullscreen mode

If the source image is 1600 pixels wide and pixelSize is 16, the temporary image is roughly 100 pixels wide.

When that tiny version is stretched back to 1600 pixels with smoothing disabled, every source pixel becomes a visible block.

A larger pixelSize creates fewer source blocks and therefore a stronger effect.

Step 3: Store selections in image coordinates

Pixelating the whole image is easy. Pixelating only a face or license plate is the more interesting problem.

The preview canvas changes size depending on the viewport and zoom level. If I stored selections in screen coordinates, they would move or resize whenever the preview changed.

Instead, every selection is stored in the coordinate system of the original image:

type Point = {
  x: number
  y: number
}

type RectSelection = {
  id: string
  kind: "rect"
  x: number
  y: number
  width: number
  height: number
}

type BrushSelection = {
  id: string
  kind: "brush"
  size: number
  points: Point[]
}

type Selection = RectSelection | BrushSelection
Enter fullscreen mode Exit fullscreen mode

Pointer coordinates must therefore be converted from the displayed canvas back into image coordinates:

function toImagePoint(
  clientX: number,
  clientY: number,
  canvas: HTMLCanvasElement,
  imageWidth: number,
  imageHeight: number,
): Point {
  const rect = canvas.getBoundingClientRect()

  const x =
    ((clientX - rect.left) / rect.width) * imageWidth

  const y =
    ((clientY - rect.top) / rect.height) * imageHeight

  return {
    x: Math.min(imageWidth, Math.max(0, x)),
    y: Math.min(imageHeight, Math.max(0, y)),
  }
}
Enter fullscreen mode Exit fullscreen mode

This is one of the most important details in the editor.

A rectangle drawn over a face is saved relative to the image, not the current screen. The same selection can then be rendered correctly in:

  • A small responsive preview
  • A zoomed preview
  • The full-resolution exported image

Step 4: Use a mask for selected-area pixelation

For selected-area mode, I first render the complete mosaic onto an offscreen canvas.

Then I create a second canvas that acts as a mask:

  • Selected pixels are opaque.
  • Everything else is transparent.

Rectangles are drawn with fillRect(). Brush strokes are drawn as thick lines with rounded ends:

function drawSelections(
  context: CanvasRenderingContext2D,
  selections: Selection[],
  scaleX: number,
  scaleY: number,
) {
  context.fillStyle = "#000"
  context.strokeStyle = "#000"
  context.lineCap = "round"
  context.lineJoin = "round"

  for (const selection of selections) {
    if (selection.kind === "rect") {
      context.fillRect(
        selection.x * scaleX,
        selection.y * scaleY,
        selection.width * scaleX,
        selection.height * scaleY,
      )

      continue
    }

    const points = selection.points
    if (points.length === 0) continue

    context.lineWidth =
      selection.size * ((scaleX + scaleY) / 2)

    context.beginPath()
    context.moveTo(
      points[0].x * scaleX,
      points[0].y * scaleY,
    )

    for (const point of points.slice(1)) {
      context.lineTo(
        point.x * scaleX,
        point.y * scaleY,
      )
    }

    context.stroke()
  }
}
Enter fullscreen mode Exit fullscreen mode

There is a subtle issue here: clipping the mosaic to the exact shape of the mask can cut through individual pixel blocks. That produces half-pixelated blocks around the edge of a selection.

To keep the mosaic visually consistent, I work at the block level instead.

For every mosaic block inside the selection bounds, I check whether the mask touches that block. If it does, I copy the entire block:

for (let blockY = startY; blockY < endY; blockY++) {
  for (let blockX = startX; blockX < endX; blockX++) {
    const sourceX = blockX * pixelSize
    const sourceY = blockY * pixelSize

    const destinationX = Math.round(sourceX * scaleX)
    const destinationY = Math.round(sourceY * scaleY)

    if (!maskTouchesBlock(
      maskData,
      destinationX,
      destinationY,
      blockWidth,
      blockHeight,
    )) {
      continue
    }

    outputContext.drawImage(
      mosaicCanvas,
      destinationX,
      destinationY,
      blockWidth,
      blockHeight,
      destinationX,
      destinationY,
      blockWidth,
      blockHeight,
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

This means a selection activates complete mosaic blocks instead of cutting through them.

The result looks cleaner, especially around brush strokes and the edges of rectangular selections.

Step 5: Use one painting function everywhere

I did not want separate algorithms for the editor preview and the downloaded file. That would eventually lead to small differences between what the user sees and what they save.

Instead, both paths call the same function:

type PaintOptions = {
  mode: "full" | "selection"
  pixelSize: number
  selections: Selection[]
}

export function paintPixelated(
  context: CanvasRenderingContext2D,
  source: CanvasImageSource,
  sourceWidth: number,
  sourceHeight: number,
  destinationWidth: number,
  destinationHeight: number,
  options: PaintOptions,
) {
  context.clearRect(
    0,
    0,
    destinationWidth,
    destinationHeight,
  )

  context.drawImage(
    source,
    0,
    0,
    sourceWidth,
    sourceHeight,
    0,
    0,
    destinationWidth,
    destinationHeight,
  )

  const mosaic = renderMosaic(
    source,
    sourceWidth,
    sourceHeight,
    destinationWidth,
    destinationHeight,
    options.pixelSize,
  )

  if (options.mode === "full") {
    context.drawImage(mosaic, 0, 0)
    return
  }

  const maskedMosaic = renderSelectedBlocks(
    mosaic,
    options.selections,
    sourceWidth,
    sourceHeight,
    destinationWidth,
    destinationHeight,
    options.pixelSize,
  )

  context.drawImage(maskedMosaic, 0, 0)
}
Enter fullscreen mode Exit fullscreen mode

For the editor, destinationWidth and destinationHeight match the responsive preview.

For export, they match the image’s full dimensions.

That distinction matters. Exporting the visible preview would reduce the final image to the size of the editor. Re-rendering at the source dimensions preserves the available resolution.

Step 6: Export without uploading anything

The export process creates a new canvas at the image’s full size and runs the same painter again:

async function exportImage(
  source: CanvasImageSource,
  width: number,
  height: number,
  options: PaintOptions,
) {
  const canvas = document.createElement("canvas")
  canvas.width = width
  canvas.height = height

  const context = canvas.getContext("2d")
  if (!context) {
    throw new Error("Could not export this image.")
  }

  paintPixelated(
    context,
    source,
    width,
    height,
    width,
    height,
    options,
  )

  const blob = await new Promise<Blob | null>((resolve) => {
    canvas.toBlob(resolve, "image/png")
  })

  if (!blob) {
    throw new Error("Could not create the image file.")
  }

  const url = URL.createObjectURL(blob)
  const link = document.createElement("a")

  link.href = url
  link.download = "pixelated-image.png"
  link.click()

  URL.revokeObjectURL(url)
}
Enter fullscreen mode Exit fullscreen mode

The whole flow stays inside the browser:

Local file
    ↓
ImageBitmap
    ↓
Canvas rendering
    ↓
Blob
    ↓
Local download
Enter fullscreen mode Exit fullscreen mode

No image data needs to be sent to Next.js or stored on a server.

What I would keep if I rebuilt it

The UI can change. The framework can change. But I would keep these three decisions:

  1. Store selections in source-image coordinates.
  2. Use the same painting function for preview and export.
  3. Keep image processing in the browser when the server adds no real value.

Those choices solved most of the difficult problems before they became UI bugs.

You can try the finished tool at Pixelate Photo. It supports whole-image pixelation, rectangle and brush selections, adjustable pixel size, comparison, undo and redo, and PNG, JPG, or WebP downloads.

If you are building something similar, start with the smallest useful pipeline: load one image, pixelate it on a canvas, and export it. Add selection tools only after that path works from beginning to end.

Top comments (0)