A real-time collaborative whiteboard for technical interviews.
Sketchly is a collaborative whiteboard application designed for technical interviews. Multiple participants can join the same room and work together in real time using a shared canvas.
The project focuses on building the core collaboration infrastructure from scratch using Next.js, Express, WebSockets, Prisma, PostgreSQL, and HTML Canvas.
- Rectangle
- Circle
- Line
- Arrow
- Text
- Image
- Stroke color selection
- Background color selection
- Adjustable canvas drawing
- Eraser
The selection tool currently supports:
- Selecting text
- Selecting images
- Moving text
- Moving images
Other shapes intentionally remain non-selectable for now.
Changes are synchronized between participants using WebSockets.
Currently synchronized:
- New shapes
- Text
- Images
- Shape deletion
- Movement of text/images
Whiteboard shapes are stored in the database as JSON.
When a participant enters a room, previously created shapes are loaded from the backend and rendered on the canvas.
Each room has:
- Unique room slug
- Room name
- Owner
- Created timestamp
- Associated shapes
Users can create and join rooms.
- Next.js
- React
- TypeScript
- Tailwind CSS
- HTML Canvas
- Lucide React
- Node.js
- Express
- WebSocket (
ws) - TypeScript
- PostgreSQL
- Prisma ORM
- pnpm
- Turborepo
┌──────────────────┐
│ Next.js │
│ Frontend │
└────────┬─────────┘
│
HTTP REST API
│
▼
┌──────────────────┐
│ Express │
│ Server │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Prisma │
│ PostgreSQL │
└──────────────────┘
┌──────────────────┐
│ Canvas │
│ Client A │
└────────┬─────────┘
│
WebSocket
│
▼
┌──────────────────┐
│ WebSocket │
│ Server │
└────────┬─────────┘
│
WebSocket
│
▼
┌──────────────────┐
│ Canvas │
│ Client B │
└──────────────────┘
When a user creates a shape:
User draws shape
↓
Canvas creates Shape object
↓
WebSocket message
↓
WebSocket server
↓
Broadcast to other users
↓
Other clients update canvas
For example:
{
type: "RECTANGLE",
shape: {
id: "abc123",
type: "RECTANGLE",
x: 100,
y: 200,
width: 300,
height: 150,
strokeColor: "#000000"
}
}The receiving clients add the shape to their local shape collection and redraw the canvas.
Every object on the canvas is represented as a typed shape.
Example:
interface RectangleShape {
id: string;
type: "RECTANGLE";
x: number;
y: number;
width: number;
height: number;
strokeColor: string;
}The application uses a discriminated union:
type Shape =
| RectangleShape
| CircleShape
| LineShape
| ArrowShape
| TextShape
| ImageShape;This allows drawShape() to determine how each object should be rendered.
Shape
│
├── RECTANGLE
├── CIRCLE
├── LINE
├── ARROW
├── TEXT
└── IMAGE
The core room model:
model Room {
id String @id @default(cuid())
slug String @unique
name String
ownerId String
owner User @relation(fields: [ownerId], references: [id])
createdAt DateTime @default(now())
shapes Shape[]
@@index([ownerId])
}Shapes are stored separately:
model Shape {
id String @id @default(cuid())
shapeData Json
roomId String
userId String
room Room @relation(fields: [roomId], references: [id])
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([roomId])
@@index([userId])
}The shapeData field allows different shape types to be stored without requiring a separate database table for every shape.
Images currently use a simple client-side approach.
When a user selects an image:
Image file
↓
FileReader
↓
Base64 Data URL
↓
Image Shape
↓
WebSocket
↓
Other participants
The image data is stored inside the shape.
Example:
{
id: "image123",
type: "IMAGE",
x: 100,
y: 100,
width: 300,
height: 200,
imageData: "data:image/png;base64,..."
}This approach keeps the MVP simple and avoids requiring an external image-storage service.
For a production-scale application, object storage would be preferable.
Canvas redraws happen frequently, especially while moving objects.
Creating a new Image() every time the canvas redraws can cause image flickering.
Sketchly therefore uses an image cache:
imageData
↓
HTMLImageElement
↓
Image Cache
↓
Canvas redraw
Once an image has loaded, subsequent redraws reuse the cached image instead of decoding it again.
The eraser operates on the shape itself rather than erasing pixels.
For example:
User clicks the stroke
↓
Detect matching shape
↓
Remove shape from local state
↓
Send DELETE_SHAPE
↓
Server removes persisted shape
↓
Broadcast deletion
This makes the eraser work consistently for vector objects, text, and images.
The Express server currently exposes room-related endpoints such as:
POST /rooms/create
GET /rooms/:slug
GET /rooms/:slug/shapes
POST /rooms/:slug/join
GET /rooms/my-rooms
Authentication is applied to protected routes using the project's authentication middleware.
The WebSocket server handles collaborative events such as:
RECTANGLE
CIRCLE
LINE
ARROW
TEXT
IMAGE
DELETE_SHAPE
MOVE_SHAPE
A typical movement event looks like:
{
"type": "MOVE_SHAPE",
"shapeId": "abc123",
"x": 500,
"y": 250
}The server updates the persisted shape and broadcasts the movement to other clients in the room.
The project uses a Turborepo-style monorepo.
A simplified structure:
sketchly/
│
├── apps/
│ ├── web/
│ │ └── Next.js application
│ │
│ ├── api/
│ │ └── Express REST API
│ │
│ └── ws/
│ └── WebSocket server
│
├── packages/
│ ├── common/
│ │ ├── types
│ │ └── canvas drawing utilities
│ │
│ └── db/
│ └── Prisma client and schema
│
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
git clone <your-repository-url>
cd sketchlypnpm installCreate the required .env files for the web application, API, WebSocket server, and database package.
Example:
DATABASE_URL="your-postgresql-connection-string"
JWT_SECRET="your-secret"Use the environment variables expected by the corresponding applications in the repository.
pnpm prisma generatepnpm prisma migrate devpnpm devThe frontend, REST API, and WebSocket server should then run together through the Turborepo development configuration.
A typical development flow is:
Create room
↓
Enter room
↓
Connect WebSocket
↓
Load persisted shapes
↓
Draw on canvas
↓
Broadcast changes
↓
Persist changes
For testing collaboration:
Browser A
│
├── Create / Join room
│
▼
WebSocket Server
│
▼
Browser B
Open the same room in two browser windows to test real-time collaboration.
This is an MVP/portfolio project, so some functionality is intentionally simplified.
- Images are currently transferred as Base64 data.
- Image storage is not yet handled by object storage.
- Selection is limited to text and images.
- Text editing is intentionally minimal.
- Shapes do not currently have advanced resize/rotation handles.
- WebSocket movement is optimized around synchronization rather than high-frequency operational transforms.
- The WebSocket server currently maintains room connections in memory.
Possible future improvements include:
- Production image storage
- Image resizing
- Resize and rotation handles
- Better text editing
- Cursor presence
- User presence indicators
- Undo / redo
- Room dashboard
- Room sharing links
- Permissions
- Drawing/freehand tool
- Shape selection for all objects
- Optimistic synchronization
- WebSocket reconnection
- Horizontal scaling with Redis/pub-sub
- Production deployment
The main goal of Sketchly is to understand how real-time collaborative applications work internally rather than relying entirely on third-party collaboration services.
The project combines:
React
+
Canvas
+
WebSockets
+
REST APIs
+
Prisma
+
PostgreSQL
to create a complete collaborative application from the ground up.
This project is available for learning and portfolio purposes.