DEV Community

hieu.dev
hieu.dev

Posted on

Building a Browser-Based Thumbnail Maker with Fabric.js and Next.js

I built click-thumb.com — a free YouTube thumbnail maker that runs entirely in the browser. No backend, no uploads, no account. Here's the technical breakdown of the interesting problems I ran into using Fabric.js inside a Next.js static site.

Why Fabric.js over raw Canvas API?

The raw Canvas API is great for rendering but painful for interactivity — you have to manually implement hit testing, object selection, drag handles, and text editing. Fabric.js gives you all of that out of the box.

For a thumbnail maker, the key Fabric.js features I needed:

  • IText — inline text editing with double-click
  • object:moving events for clamped dragging
  • toDataURL() with a multiplier for full-resolution export
  • canvas.sendToBack() for layer ordering

The SSR problem

Fabric.js uses document and window directly — it breaks Next.js server-side rendering immediately. The fix is next/dynamic with ssr: false:

// CanvasToolClient.tsx
const CanvasEditor = dynamic(() => import('./CanvasEditor'), {
  ssr: false,
  loading: () => (
    <div className="w-full h-64 bg-surface rounded-xl animate-pulse" />
  ),
})
Enter fullscreen mode Exit fullscreen mode

The loading fallback prevents layout shift while Fabric.js loads. Without it you get a blank space that jumps to the canvas size — a visible CLS hit.

The scaling challenge

YouTube thumbnails are 1280×720px. Rendering a canvas that large directly would overflow on any mobile screen. The solution: render at full resolution internally, but display at a smaller size using CSS transform: scale().

// CanvasEditor.tsx
const { w: displayW, h: displayH } = getDisplayDimensions(platform)
const scale = displayW / platform.width // e.g. 640/1280 = 0.5

// Canvas renders at full 1280×720
const canvas = new fabric.Canvas(canvasRef.current, {
  width: displayW,   // display width (e.g. 640px)
  height: displayH,  // display height (e.g. 360px)
})

// CSS scales the wrapper to fit screen
<div style={{
  transformOrigin: 'top left',
  transform: `scale(${cssScale})`,
}}>
  <canvas ref={canvasRef} />
</div>
Enter fullscreen mode Exit fullscreen mode

All text positions and font sizes in templates are stored at full resolution, then multiplied by scale when applied to the canvas. This keeps templates resolution-independent.

Exporting at full resolution

This tripped me up initially. canvas.toDataURL() by default exports at the display size, not the full 1280×720px. The fix is the multiplier parameter:

const multiplier = platform.width / displayW // e.g. 1280/640 = 2

const dataUrl = canvas.toDataURL({
  format: 'jpeg',
  quality: 0.92,
  multiplier, // scales up to full resolution on export
})

// Convert dataURL to Blob for download
const blob = await fetch(dataUrl).then((r) => r.blob())
Enter fullscreen mode Exit fullscreen mode

The fetch(dataUrl).then(r => r.blob()) pattern is a clean way to convert a data URL to a Blob without manual base64 parsing.

Constraining drag to canvas bounds

By default, Fabric.js lets users drag objects completely off the canvas edge. For a thumbnail tool, that's a bad experience — text disappearing off-screen is confusing. I added clamping in object:moving:

canvas.on('object:moving', (e) => {
  const obj = e.target
  if (obj.type === 'i-text' || obj.type === 'text') {
    const hw = obj.getScaledWidth() / 2
    const hh = obj.getScaledHeight() / 2
    obj.left = Math.max(hw, Math.min(displayW - hw, obj.left))
    obj.top = Math.max(hh, Math.min(displayH - hh, obj.top))
  }

  if (obj.type === 'image') {
    // Background image: clamp so no black edges show
    const sw = obj.getScaledWidth()
    const sh = obj.getScaledHeight()
    obj.left = Math.max(displayW - sw, Math.min(0, obj.left))
    obj.top = Math.max(displayH - sh, Math.min(0, obj.top))
  }
})
Enter fullscreen mode Exit fullscreen mode

Text is clamped by its own half-width/height so it stays fully visible. Background images are clamped inversely — they must always cover the canvas edges so no black border shows.

The black border bug from loadFromJSON

Early on, I used canvas.loadFromJSON() to reset templates. This caused a visible black border flash on every reset. After debugging, I found that loadFromJSON briefly clears the canvas and repaints — and during that repaint frame, the background is black.

The fix: instead of rebuilding the canvas on reset, directly mutate the existing objects' properties:

const handleReset = () => {
  const textObjs = canvas.getObjects()
    .filter(obj => obj.type === 'i-text' || obj.type === 'text')

  template.texts.forEach((preset, i) => {
    const obj = textObjs[i]
    if (!obj) return
    obj.set({
      text: preset.text,
      left: preset.left * scale,
      top: preset.top * scale,
    })
    obj.setCoords() // required after .set() for hit testing to update
  })

  canvas.renderAll()
}
Enter fullscreen mode Exit fullscreen mode

No flash, no repaint cycle, just a clean synchronous update.

Ctrl+Z without an undo stack

Full undo/redo is complex to implement with Fabric.js. For this tool, the main reset use case is "I messed up the layout, go back to default." So instead of tracking state history, I map Ctrl+Z directly to the template reset:

useEffect(() => {
  const handler = (e: KeyboardEvent) => {
    if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
      e.preventDefault()
      handleReset()
    }
  }
  window.addEventListener('keydown', handler)
  return () => window.removeEventListener('keydown', handler)
}, [handleReset])
Enter fullscreen mode Exit fullscreen mode

This covers 95% of the actual undo use cases without the complexity of a full history stack.

Dynamic import of Fabric inside the effect

Fabric.js is ~300KB gzipped — too large to block initial page load. Even with ssr: false, if you import it at the top of the file it gets bundled with the component and loaded immediately when the component mounts.

Instead, import it lazily inside the useEffect:

useEffect(() => {
  let mounted = true

  ;(async () => {
    const fabric = (await import('fabric')).fabric
    if (!mounted || !canvasRef.current) return

    const canvas = new fabric.Canvas(canvasRef.current, { ... })
    // ...
  })()

  return () => {
    mounted = false
    if (fabricRef.current) {
      fabricRef.current.dispose() // important: releases canvas memory
      fabricRef.current = null
    }
  }
}, [platform.id])
Enter fullscreen mode Exit fullscreen mode

The mounted flag prevents state updates if the component unmounts before the async import resolves. The dispose() call in cleanup releases Fabric's internal canvas resources — without it, switching between tools leaks memory.

Result

The tool loads fast, exports at correct resolution, and handles the edge cases that trip up most canvas implementations. Try it at click-thumb.com — currently supports YouTube, gaming (CS2, Minecraft, Fortnite, Roblox), TikTok, Instagram, and more.

Happy to answer questions about any of the implementation details in the comments.

Top comments (0)