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:movingevents 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" />
),
})
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>
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())
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))
}
})
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()
}
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])
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])
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)