High-Quality Image Export with Konva and NextJS

Introduction

In this article, I will guide you through using Konva to export content you've created. Exporting can be done on either the frontend or backend, each with its own pros and cons:

  • Frontend: Pushing processing to the frontend means the backend doesn't consume extra resources for this task. However, when exporting a template with too many elements, a user's machine with a weak configuration might not handle it, causing the app to crash.
  • Backend: If the backend handles exporting, it will incur extra server costs. But in return, we can configure system scaling to support exporting large, complex templates. This simplifies backend processing and ensures the user interface always runs smoothly on the frontend.

In short, if this export function isn't a highly critical part of your system that needs prioritization, you can let the frontend handle it. If you are deploying a commercial product, you should handle this on the backend to provide a smooth experience for the end-user.

Prerequisites

This article will continue to develop from the content of the previous article. If anything is unclear, please review it.


Detail

In this article, I will use NextJS, a full-stack framework that includes API creation to handle exporting. The workflow will be as follows:

  1. A user creates a template; upon exporting, they call an API with a payload containing the template information.
  2. The NextJS server receives the information and creates an image.
  3. The image will be uploaded to MinIO (a self-hosted storage) that functions similarly to AWS S3. You can easily switch to AWS S3 when deploying to production.
  4. After a successful upload, a URL is returned for the frontend to download the image.


First, install the following additional packages:

yarn add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner canvas sharp


Create the file type.ts:

import type Konva from 'konva'

export interface ImageElementProps {
src?: string
commonProps: Konva.NodeConfig & {id: string}
}

export type ElementType = 'rect' | 'circle' | 'pen' | 'image'

export interface WhiteboardElement {
id: string
type: ElementType
x?: number
y?: number
width?: number
height?: number
fill?: string
stroke?: string
points?: number[]
src?: string
scaleX?: number
scaleY?: number
rotation?: number
}

export interface WhiteboardState {
elements: Record<string, WhiteboardElement>
elementIds: string[]
selectedId: string | null
tool: 'select' | 'pen'

setTool: (tool: 'select' | 'pen') => void
setSelectedId: (id: string | null) => void
addElement: (element: WhiteboardElement) => void
updateElement: (id: string, attrs: Partial<WhiteboardElement>) => void
updateLine: (id: string, point: {x: number; y: number}) => void
clearBoard: () => void
}


Update the file store.ts:

import {create} from 'zustand'
import {devtools} from 'zustand/middleware'
import {immer} from 'zustand/middleware/immer'
import type {WhiteboardState} from './type'

export const useWhiteboardStore = create<WhiteboardState>()(
devtools(
immer(set => ({
elements: {},
elementIds: [],
selectedId: null,
tool: 'select',

setTool: tool =>
set(state => {
state.tool = tool
}),

setSelectedId: id =>
set(state => {
state.selectedId = id
}),

addElement: element =>
set(state => {
state.elements[element.id] = element
state.elementIds.push(element.id)
}),

updateElement: (id, attrs) =>
set(state => {
if (state.elements[id]) {
state.elements[id] = {...state.elements[id], ...attrs}
}
}),

updateLine: (id, point) =>
set(state => {
const line = state.elements[id]
if (line && line.points) {
line.points.push(point.x, point.y)
}
}),

clearBoard: () =>
set(state => {
state.elements = {}
state.elementIds = []
state.selectedId = null
}),
})),
{name: 'WhiteboardStore'}
)
)

Set up the Store to manage the whiteboard's state: store the list of elements, manage tool selection (draw/select), and provide functions to update data optimally.


Update the file ImageElement.tsx:

import {Image as KonvaImage} from 'react-konva'
import useImage from 'use-image'
import type {ImageElementProps} from './type'

export const ImageElement = ({src, commonProps}: ImageElementProps) => {
const [img] = useImage(src || '')
return <KonvaImage {...commonProps} image={img} />
}


Update the file RenderElement.tsx:

import type {KonvaEventObject} from 'konva/lib/Node'
import {memo} from 'react'
import {Circle, Line, Rect} from 'react-konva'
import {ImageElement} from './ImageElement'
import {useWhiteboardStore} from './store'

export const RenderElement = memo(({id}: {id: string}) => {
const element = useWhiteboardStore(state => state.elements[id])
const updateElement = useWhiteboardStore(state => state.updateElement)
const setSelectedId = useWhiteboardStore(state => state.setSelectedId)

if (!element) return null

const cleanElement = {
...element,
points: element.points ? [...element.points] : undefined,
}
const commonProps = {
...cleanElement,
id: element.id,
draggable: true,
onClick: (e: any) => {
e.cancelBubble = true
setSelectedId(id)
},
onTap: (e: any) => {
e.cancelBubble = true
setSelectedId(id)
},
onDragEnd: (e: KonvaEventObject<DragEvent>) => {
updateElement(id, {x: e.target.x(), y: e.target.y()})
},
onTransformEnd: (e: KonvaEventObject<Event>) => {
const node = e.target
updateElement(id, {
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
})
},
}

switch (element.type) {
case 'rect':
return <Rect {...commonProps} />
case 'circle':
return (
<Circle
{...commonProps}
radius={(element.width! * (element.scaleX || 1)) / 2}
/>
)
case 'pen':
return (
<Line
{...commonProps}
points={cleanElement.points}
strokeWidth={5}
tension={0.5}
lineCap="round"
lineJoin="round"
hitStrokeWidth={20}
name="selectable-line"
scaleX={element.scaleX || 1}
scaleY={element.scaleY || 1}
rotation={element.rotation || 0}
/>
)
case 'image':
return <ImageElement src={element.src} commonProps={commonProps} />
default:
return null
}
})

This component acts as a render filter: based on the element's type in the store, it draws the corresponding Konva component while also handling drag (Drag) and transform (Transform) events to update new coordinates in the store.


Update the file WhiteBoard.tsx:

import {
BorderOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
PictureOutlined,
SelectOutlined,
StopOutlined,
} from '@ant-design/icons'
import {Button, Card, Divider, Space, Tag, Typography, message} from 'antd'
import Konva from 'konva'
import {KonvaEventObject} from 'konva/lib/Node'
import {ChangeEvent, useEffect, useRef, useState} from 'react'
import {Layer, Stage, Transformer} from 'react-konva'
import {RenderElement} from './RenderElement'
import {useWhiteboardStore} from './store'

const {Text} = Typography

const Whiteboard = () => {
const {
elementIds,
tool,
selectedId,
setTool,
addElement,
updateLine,
setSelectedId,
clearBoard,
} = useWhiteboardStore()

const stageRef = useRef<Konva.Stage>(null)
const trRef = useRef<Konva.Transformer>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const isDrawing = useRef(false)
const currentLineId = useRef<string | null>(null)
const [isExporting, setIsExporting] = useState(false)

useEffect(() => {
if (trRef.current && stageRef.current) {
const nodes = selectedId
? [stageRef.current.findOne('#' + selectedId)]
: []
trRef.current.nodes(nodes.filter((n): n is Konva.Node => !!n))
trRef.current.getLayer()?.batchDraw()
}
}, [selectedId])

const handleExport = async () => {
if (!stageRef.current) return

setIsExporting(true)
const hideLoading = message.loading(
'Đang chuẩn bị file và upload lên hệ thống...',
0
)

try {
if (trRef.current) trRef.current.hide()
const stageJson = stageRef.current.toJSON()

if (trRef.current) trRef.current.show()
const response = await fetch('/api/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
stageJson,
fileName: `design-${Date.now()}`,
format: 'png',
}),
})
const data = await response.json()

if (data.url) {
message.success('Export successful!')
window.location.href = data.url
} else {
throw new Error(data.error || 'Export failed')
}
} catch (error) {
console.error('Export Error:', error)
message.error('Export error')
} finally {
setIsExporting(false)
hideLoading()
}
}

const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (tool !== 'pen') return
const stage = e.target.getStage()
const pos = stage?.getPointerPosition()
if (!pos) return

isDrawing.current = true
const id = `line-${Date.now()}`
currentLineId.current = id

addElement({
id,
type: 'pen',
x: 0,
y: 0,
points: [pos.x, pos.y],
stroke: '#df4b26',
scaleX: 1,
scaleY: 1,
rotation: 0,
})
}

const handleMouseMove = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (!isDrawing.current || tool !== 'pen' || !currentLineId.current) return
const stage = e.target.getStage()
const pos = stage?.getPointerPosition()
if (pos) updateLine(currentLineId.current, pos)
}

const handleMouseUp = () => {
isDrawing.current = false
currentLineId.current = null
}

const addShape = (type: 'rect' | 'circle') => {
const id = `${type}-${Date.now()}`
addElement({
id,
type,
x: 100 + Math.random() * 50,
y: 100 + Math.random() * 50,
width: 100,
height: 100,
fill: '#3b82f6',
scaleX: 1,
scaleY: 1,
})
setSelectedId(id)
}

const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return

Array.from(files).forEach((file, index) => {
const reader = new FileReader()
reader.onload = event => {
const id = `image-${Date.now()}-${index}`
addElement({
id,
type: 'image',
src: event.target?.result as string,
x: 50 + index * 30,
y: 50 + index * 30,
scaleX: 0.5,
scaleY: 0.5,
})
}
reader.readAsDataURL(file)
})
e.target.value = ''
}

return (
<div className="flex flex-col items-center min-h-screen bg-slate-100 p-8 font-sans">
<input
type="file"
ref={fileInputRef}
multiple
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>

<Card className="shadow-2xl rounded-3xl w-full max-w-[900px] bg-white border-none overflow-hidden">
<div className="flex flex-wrap justify-between items-center mb-6 gap-4 p-2">
<h2 className="text-2xl font-black text-slate-800 !m-0 tracking-tight">
Design Studio
</h2>

<Space className="bg-slate-50 p-1.5 rounded-xl border border-slate-200 shadow-sm">
<Button
type={tool === 'select' ? 'primary' : 'text'}
icon={<SelectOutlined />}
onClick={() => setTool('select')}
>
Select
</Button>
<Button
type={tool === 'pen' ? 'primary' : 'text'}
icon={<EditOutlined />}
onClick={() => setTool('pen')}
>
Pen
</Button>
<Divider orientation="vertical" className="h-6 mx-1" />
<Button icon={<BorderOutlined />} onClick={() => addShape('rect')}>
Rect
</Button>
<Button icon={<StopOutlined />} onClick={() => addShape('circle')}>
Circle
</Button>
<Button
icon={<PictureOutlined />}
onClick={() => fileInputRef.current?.click()}
>
Images
</Button>

<Divider orientation="vertical" className="h-6 mx-1" />

<Button
type="primary"
className="bg-blue-600 hover:bg-blue-700"
icon={<DownloadOutlined />}
onClick={handleExport}
loading={isExporting}
>
Export
</Button>
<Button
danger
type="text"
icon={<DeleteOutlined />}
onClick={clearBoard}
/>
</Space>
</div>

<div className="border-2 border-slate-100 rounded-2xl overflow-hidden bg-white shadow-inner flex justify-center">
<Stage
width={800}
height={500}
ref={stageRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
onClick={e =>
e.target === e.target.getStage() && setSelectedId(null)
}
className="cursor-crosshair"
>
<Layer>
{elementIds.map(id => (
<RenderElement key={id} id={id} />
))}
<Transformer
ref={trRef}
anchorSize={8}
keepRatio={false}
enabledAnchors={[
'top-left',
'top-center',
'top-right',
'middle-right',
'middle-left',
'bottom-left',
'bottom-center',
'bottom-right',
]}
rotateEnabled={true}
rotateAnchorOffset={30}
borderStroke="#3b82f6"
anchorCornerRadius={2}
padding={5}
ignoreStroke={false}
boundBoxFunc={(oldBox, newBox) => {
if (
Math.abs(newBox.width) < 5 ||
Math.abs(newBox.height) < 5
) {
return oldBox
}
return newBox
}}
/>
</Layer>
</Stage>
</div>

<div className="mt-6 flex justify-between items-center px-2">
<Space>
<Tag
color={tool === 'pen' ? 'orange' : 'blue'}
className="rounded-full px-3"
>
{tool.toUpperCase()} MODE
</Tag>
<Text type="secondary" className="text-xs font-medium">
Objects: {elementIds.length}
</Text>
</Space>
</div>
</Card>
</div>
)
}

export default Whiteboard

Main interface component: Manages Konva's Stage, drawing tools, and specifically the handleExport logic to convert the entire Stage structure into JSON and send it to the backend API for file exporting.


Create the file route.ts for the API used to export:

import {GetObjectCommand, PutObjectCommand, S3Client} from '@aws-sdk/client-s3'
import {getSignedUrl} from '@aws-sdk/s3-request-presigner'
import {Image as CanvasImage} from 'canvas'
import Konva from 'konva'
import 'konva/lib/canvas-backend'
import {NextResponse} from 'next/server'
import sharp from 'sharp'

if (typeof window === 'undefined') {
Konva.isBrowser = false
}

const s3Client = new S3Client({
endpoint: process.env.MINIO_ENDPOINT,
forcePathStyle: true,
})

const BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME

export async function POST(req: Request) {
try {
const {stageJson, fileName, format = 'png'} = await req.json()
const stage = Konva.Node.create(stageJson) as Konva.Stage
const imageNodes = stage.find('Image') as Konva.Image[]
const loadImages = imageNodes.map(node => {
return new Promise<void>(async resolve => {
const src = node.getAttr('src')
if (!src) return resolve()

try {
const base64Content = src.split(';base64,').pop()
const inputBuffer = Buffer.from(base64Content, 'base64')
const pngBuffer = await sharp(inputBuffer).png().toBuffer()
const img = new CanvasImage()

img.onload = () => {
node.image(img)
resolve()
}
img.onerror = err => {
console.error('Canvas load error:', err)
resolve()
}
img.src = pngBuffer
} catch (err) {
console.error('Sharp processing error:', err)
resolve()
}
})
})

await Promise.all(loadImages)

const pixelRatio = 3
const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png'
const dataUrl = stage.toDataURL({pixelRatio, mimeType})
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
const buffer = Buffer.from(base64Data, 'base64')
const s3Key = `exports/${Date.now()}_${fileName}.${format}`

await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: s3Key,
Body: buffer,
ContentType: mimeType,
})
)

const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: s3Key,
ResponseContentDisposition: `attachment; filename="${fileName}.${format}"`,
}),
{expiresIn: 3600}
)

return NextResponse.json({url: downloadUrl})
} catch (error) {
console.error('S3 Export Error:', error)
return NextResponse.json({error: 'Export failed'}, {status: 500})
}
}

Backend Export API: Receives JSON data from the frontend, uses canvas to reconstruct the image on the server, optimizes the image with sharp, then uploads it to MinIO/S3 and returns a secure download link for the user.


Update the .env file with your information as appropriate. Here, I use MinIO for uploading. If you want to use AWS S3, you only need to change the credential information accordingly.

AWS_ACCESS_KEY_ID = YOUR_ACCESS_KEY
AWS_SECRET_ACCESS_KEY = YOUR_SECRET_KEY
AWS_REGION = us-east-1
AWS_S3_BUCKET_NAME = test
MINIO_ENDPOINT = http://127.0.0.1:9000


Test that the export will call the corresponding API.


Result when exporting with all elements as created.


Happy coding!

See more articles here.

Comments