Excalidraw for documents — visual PDF annotation and async review for teams.
Overview · Features · Tech Stack · Architecture · Getting Started · Usage · API · Roadmap
Markly is a self-hostable PDF annotation and async document review platform. Think Excalidraw, except the canvas isn't blank — your document is the base layer.
Upload a PDF (or a DOCX, converted server-side), mark it up freehand, drop shapes and text anywhere, leave threaded comments, then hand someone a link. Reviewers don't need an account, don't need to install anything, and can send their markup back to you with one click. When you're done, export a flat PDF with every annotation baked into the bytes — fonts embedded, positions identical to what you saw on screen.
Who it's for: designers collecting feedback on mockups, lawyers redlining contracts, architects marking up plans, product teams reviewing specs — anyone whose review loop currently lives in email attachments named final_v3_REVISED.pdf.
Why it exists. Comment threads in Google Docs don't work for visual documents. Acrobat's annotation tools do, but they lock reviewers into an account and a desktop install. Markly sits in between: real drawing tools on a real PDF, shared by URL, running entirely on free-tier infrastructure.
- Eight tools — select, rectangle, circle, arrow, line, highlight, freehand, text
- Click-to-type anywhere with live inline editing and per-annotation color and size
- Multiple fonts — Virgil (Excalidraw's hand-drawn face), Helvetica, Times, Courier — chosen from a font picker and preserved through export
- Multi-page navigation with a rendered thumbnail sidebar
- Zoom that keeps annotations locked to their document coordinates
- Full undo/redo across the session (
Ctrl+Z/Ctrl+Y)
- Zero-login review links with
VIEW,COMMENT, orEDITaccess, optionally time-limited - Share-back flow — a reviewer's markup branches into a copy of the document and returns as a new link, so the original is never overwritten
- Threaded comments anchored to a specific annotation or page, resolvable inline
- Debounced autosave with a live save indicator, plus manual
Ctrl+S
- PDF and DOCX uploads — DOCX is converted to PDF server-side via LibreOffice where available
- Direct-to-storage uploads via presigned URLs, so large files never pass through a serverless function
- Annotated PDF export through
pdf-lib, with custom fonts embedded and text wrapping that mirrors the editor canvas - Originals stay untouched — annotations live in the database, never written into the source file
- Firebase Authentication with server-verified session cookies
- Per-user workspaces and projects
- Pluggable storage: local disk in development, Cloudflare R2 in production, selected from env alone
- End-to-end TypeScript with Zod-validated request bodies
- Runs entirely on free tiers — no paid service anywhere in the stack
| Layer | Technology | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | Server components and API routes in one deployable |
| UI | React 19 + Tailwind CSS v4 | Minimal black-and-white design system via CSS variables |
| Language | TypeScript 5 | Type safety across client, server, and DB |
| Client state | Zustand | Small, synchronous editor store (tools, history, viewport) |
| Server state | TanStack React Query | Caching, refetching, mutation lifecycle |
| PDF rendering | pdf.js (pdfjs-dist v5) |
The reference PDF renderer |
| Annotation canvas | HTML Canvas + Konva + Rough.js | Direct pixel control, hand-drawn stroke styling |
| PDF export | pdf-lib + @pdf-lib/fontkit |
Byte-level PDF authoring with font embedding |
| Auth | Firebase Auth + Firebase Admin | Client sign-in, server-side session cookie verification |
| ORM | Prisma 7 | Typed queries, migrations, JSON columns |
| Database | PostgreSQL 16 (Neon free tier) | Relational data with JSON geometry/style columns |
| Object storage | Cloudflare R2 via AWS S3 SDK | S3-compatible, 10 GB free, zero egress fees |
| Validation | Zod v4 | Runtime schema validation at API boundaries |
Annotations store geometry and style as JSON columns, so new tool types don't require a migration. Documents carry an isVisible flag — share-back branches are created hidden until the owner accepts them.
Every document-scoped route resolves access through one of two paths:
- Session cookie —
markly_session, an HTTP-only Firebase session cookie (5-day lifetime) verified server-side by Firebase Admin, then matched against document ownership. - Share token — a
?token=query param resolved to aShareLink, checked for expiry and ranked access level (VIEW<COMMENT<EDIT) against what the route requires.
This is what makes login-free review possible without introducing a second, weaker auth system.
PDF bytes never pass through an API route:
browser → POST /api/documents/upload-url auth + project check → presigned PUT
browser → PUT https://<account>.r2.cloudflarestorage.com/… the actual bytes
browser → POST /api/documents/upload { filename } → verify object exists → DB row
Serverless request bodies are capped at 4.5 MB on Vercel; streaming a 50 MB PDF through a function is impossible there and wasteful everywhere else. The server calls fileExists() before writing the row, so a client can't fabricate an orphaned document record. DOCX still uses the multipart path because it needs server-side LibreOffice.
lib/storage.ts picks a driver from the environment — all four R2_* variables set means R2, otherwise local disk at STORAGE_PATH. Call sites never branch, so npm run dev works with zero cloud credentials.
lib/fonts.ts is a single dependency-free registry shared by the canvas, the toolbar picker, and the export route. Each font declares a CSS stack for on-screen rendering plus either a pdf-lib standard font or a file under public/fonts to embed. The export route re-implements the canvas's text wrapping against the embedded font's real metrics, which is why exported text breaks lines in the same places the editor did.
- Node.js 18+
- PostgreSQL 14+ — local, Docker, or a free Neon project
- A Firebase project with Authentication enabled (Google and/or email sign-in)
- Optional: LibreOffice on the host if you want DOCX uploads
git clone https://github.com/KUNDAN1334/Markly.git
cd Markly/markly
npm installpostinstall runs prisma generate automatically.
cp .env.local.example .env.local| Variable | Required | Notes |
|---|---|---|
DATABASE_URL |
✅ | Postgres connection string. Use the pooled URL on Neon. |
NEXT_PUBLIC_APP_URL |
✅ | Public origin, no trailing slash. Share links are built from this. |
NEXT_PUBLIC_FIREBASE_* |
✅ | Web app config from the Firebase console |
FIREBASE_PROJECT_ID / FIREBASE_CLIENT_EMAIL / FIREBASE_PRIVATE_KEY |
✅ | Service account for server-side verification. Keep the \n escapes in the key and wrap it in quotes. |
STORAGE_PATH |
— | Local upload directory. Ignored when R2 is configured. |
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET |
— | Setting all four switches storage to Cloudflare R2. |
DOCX_CONVERSION_ENABLED |
— | Set false on serverless hosts with no LibreOffice binary. |
LIBREOFFICE_PATH |
— | Explicit path to soffice if it isn't on PATH. |
Running Postgres in Docker
docker run -d --name markly-db \
-e POSTGRES_DB=markly \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 postgres:16Then set DATABASE_URL="postgresql://postgres:password@localhost:5432/markly".
npx prisma migrate deploy # apply migrations
npx prisma generate # regenerate the client if neededmkdir -p storage/documentsSkip this if you're using R2.
npm run devOpen http://localhost:3000.
| Script | Purpose |
|---|---|
npm run dev |
Development server |
npm run build |
Production build |
npm run start |
Serve the production build |
npm run lint |
ESLint |
DEPLOYMENT.md walks through the full free-tier deployment — Neon for Postgres, Cloudflare R2 for documents, Vercel for the app, Firebase for auth — including the R2 CORS policy that presigned uploads need and the Firebase authorized-domain step that Google sign-in needs.
1. Sign in. Google or email via Firebase. A workspace is created on first sign-in, and any orphaned projects are adopted into it.
2. Create a project and upload. Drag a PDF or DOCX onto the upload zone on the dashboard. DOCX is converted to PDF server-side; the original is preserved either way.
3. Annotate. Pick a tool from the toolbar and draw. Text annotations are created by clicking anywhere and typing — set font, size, and color before or after. Changes autosave on a debounce, the header shows save state, and Ctrl+S forces a save.
4. Comment. Open the sidebar to leave a comment on the current page, or attach one to a selected annotation. Resolve threads inline as they're addressed.
5. Share. Use the share modal to mint a link with the access level you want. Reviewers open /review/<token> — no account, no install. VIEW is read-only, COMMENT adds the sidebar, EDIT gives the full toolbar.
6. Collect and export. A reviewer with EDIT access can share their markup back, which branches the document so your original stays intact. When you're finished, export a flat PDF with every annotation embedded.
| Key | Action |
|---|---|
S |
Select |
R |
Rectangle |
C |
Circle |
A |
Arrow |
L |
Line |
H |
Highlight |
F |
Freehand |
T |
Text |
Delete / Backspace |
Delete selection |
Ctrl/⌘ + Z |
Undo |
Ctrl/⌘ + Y or Ctrl/⌘ + Shift + Z |
Redo |
Ctrl/⌘ + S |
Save now |
Ctrl/⌘ + Enter |
Commit inline text edit |
Esc |
Cancel inline text edit |
markly/
├── app/
│ ├── page.tsx # Landing page
│ ├── login/ · signup/ # Firebase auth screens
│ ├── dashboard/page.tsx # Projects and documents
│ ├── editor/[documentId]/ # Full annotation editor
│ ├── review/[token]/ # Login-free share view
│ └── api/
│ ├── auth/session/ # Session cookie mint / read / clear
│ ├── workspaces/current/ # Current user's workspace
│ ├── projects/ # List, create, read, delete
│ ├── documents/
│ │ ├── upload-url/ # Presigned PUT for direct upload
│ │ ├── upload/ # Finalize upload / DOCX multipart path
│ │ ├── file/[filename]/ # Stream document bytes
│ │ └── [id]/ # Document metadata
│ ├── annotations/ # Bulk replace + per-annotation ops
│ ├── comments/ # Create, list, resolve, delete
│ ├── share/ # Create, resolve, revoke share links
│ └── export/ # Annotated PDF generation
│
├── components/
│ ├── editor/ # PDFViewer, AnnotationCanvas, Toolbar, PageThumbnails
│ ├── comments/CommentsSidebar.tsx
│ ├── documents/UploadZone.tsx
│ ├── sharing/ShareModal.tsx
│ ├── auth/AuthScreen.tsx
│ ├── landing/LandingPageClient.tsx
│ ├── MorphLoader.tsx # Shared route/transition loader
│ └── Providers.tsx # React Query provider
│
├── lib/
│ ├── auth.ts # Session verification + access authorization
│ ├── share-link-auth.ts # Share token validation and access ranking
│ ├── storage.ts # R2 / local-disk storage driver
│ ├── fonts.ts # Shared font registry (canvas + export)
│ ├── document-conversion.ts # DOCX → PDF via LibreOffice
│ ├── document-branch.ts # Share-back document branching
│ ├── workspace.ts # Workspace bootstrap for a user
│ ├── store.ts # Zustand editor store
│ ├── prisma.ts · firebase-*.ts # Client singletons
│ └── utils.ts
│
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── public/fonts/ # Virgil.otf / Virgil.woff2
├── public/pdf.worker.min.mjs # pdf.js worker
└── types/index.ts # Shared domain types
All endpoints return JSON; errors take the shape { error: string }. Document-scoped routes accept either a session cookie or a ?token= share token.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/auth/session |
Exchange a Firebase ID token for a session cookie |
GET |
/api/auth/session |
Current session user |
DELETE |
/api/auth/session |
Sign out |
GET |
/api/workspaces/current |
Current user's workspace |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/projects |
List the caller's projects |
POST |
/api/projects |
Create — { name } |
GET |
/api/projects/:id |
Project with documents |
DELETE |
/api/projects/:id |
Delete project and all nested data |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/documents/upload-url |
Presigned PUT URL for direct-to-storage upload |
POST |
/api/documents/upload |
Finalize an upload, or multipart DOCX upload |
GET |
/api/documents/file/:filename?documentId=&token= |
Stream document bytes |
GET |
/api/documents/:id |
Document metadata with annotations and comments |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/annotations?documentId= |
All annotations for a document |
POST |
/api/annotations |
Bulk replace — { documentId, annotations[] } |
PATCH |
/api/annotations/:id |
Update one annotation |
DELETE |
/api/annotations/:id |
Delete one annotation |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/comments?documentId= |
All comments for a document |
POST |
/api/comments |
Create — { content, documentId, pageNumber, annotationId? } |
PATCH |
/api/comments/:id |
Resolve / update |
DELETE |
/api/comments/:id |
Delete |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/share?token= |
Resolve a share link to its project and documents |
GET |
/api/share?projectId= or ?documentId= |
List existing links |
POST |
/api/share |
Create — { projectId, documentId?, access, expiresAt? } |
DELETE |
/api/share/:id |
Revoke a link |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/export?documentId=&token= |
Download a PDF with annotations embedded |
- Real-time collaboration — live cursors and shared presence over WebSockets
- Selection polish — multi-select, group move, snap-to-grid
- Version history — diff two branches of a document side by side
- Comment mentions and notifications —
@userwith email digests - More export targets — flattened PNG per page, annotation-only overlays
- DOCX conversion without LibreOffice — external conversion API behind the existing
convertDocxToPdfBuffersignature, so serverless deployments regain DOCX support - Mobile and tablet editing — touch-first toolbar, stylus pressure support
- Accessibility pass — full keyboard navigation of the canvas, screen-reader annotation summaries
Issues and pull requests are welcome. If you're picking up an open issue, a quick comment claiming it saves duplicate work. Keep changes focused, match the existing patterns, and run npm run lint before opening a PR.
MIT
