What it actually takes to build a DocuSign clone without DocuSeal. Every component, library, gotcha, and hour estimate.
Render each PDF page as a high-resolution canvas image so users can drag and drop signature fields on top of the correct positions.
import * as pdfjsLib from 'pdfjs-dist'
const pdf = await pdfjsLib.getDocument(fileUrl).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.5 })
const canvas = document.getElementById('pdf-canvas')
const ctx = canvas.getContext('2d')
await page.render({ canvasContext: ctx, viewport }).promise
// Now overlay field positions on top of this canvas
Drag-and-drop interface to place signature, date, text, initials, and checkbox fields at exact pixel coordinates on the PDF page.
// Store field as % of page dimensions
const field = {
type: 'signature', // signature | text | date | checkbox
page: 1,
x: 0.32, // 32% from left
y: 0.74, // 74% from top
width: 0.25,
height: 0.06,
required: true,
signer_role: 'Client'
}
Canvas-based signature drawing for mouse and touch. Must capture as SVG or PNG. Also support typed signatures and uploaded image signatures.
import SignaturePad from 'signature_pad'
const canvas = document.getElementById('sig-pad')
const pad = new SignaturePad(canvas, {
backgroundColor: 'rgba(255,255,255,0)',
penColor: '#000000',
})
// Get signature as PNG data URL
const dataURL = pad.toDataURL('image/png')
// Or as SVG for vector quality
const svgString = pad.toSVG()
After all parties sign, embed actual signatures as images into the PDF at the correct coordinates. Flatten the PDF so fields are baked in and cannot be altered.
import { PDFDocument } from 'pdf-lib'
const pdfBytes = await fetch(originalPdfUrl).then(r => r.arrayBuffer())
const pdfDoc = await PDFDocument.load(pdfBytes)
const page = pdfDoc.getPage(0)
const { width, height } = page.getSize()
// Embed signature image at field coordinates
const sigImageBytes = Buffer.from(sigDataUrl.split(',')[1], 'base64')
const sigImage = await pdfDoc.embedPng(sigImageBytes)
page.drawImage(sigImage, {
x: field.x * width,
y: height - (field.y * height) - (field.height * height),
width: field.width * width,
height: field.height * height,
})
const finalPdfBytes = await pdfDoc.save()
// Upload to Supabase Storage
Each signer gets a unique URL with a signed JWT token. No account needed. Token expires after X days. Validates identity via email click.
import { SignJWT } from 'jose'
// Generate signer token (sent in email)
const token = await new SignJWT({
submission_id: 'sub_abc123',
signer_email: 'client@example.com',
role: 'Client',
})
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret)
// Signer URL:
// https://signnee.online/sign/{token}
// Verify on page load
const { payload } = await jwtVerify(token, secret)
// payload.submission_id → load document
Every action (opened, signed, IP address, timestamp, user agent) must be recorded in a tamper-evident log. Required for legal admissibility (ESIGN Act, UETA).
// Each audit event hashes the previous event
// creating a tamper-evident chain
const auditEvent = {
submission_id,
event: 'signed', // opened|signed|declined|completed
signer_email,
ip_address: req.headers['x-forwarded-for'],
user_agent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
prev_hash: previousEventHash,
}
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(auditEvent))
.digest('hex')
// Store event + hash in audit_log table
Transactional emails for: signing request, signed notification, completion with PDF attached, reminders for pending signers, declined alerts.
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
// Send signing request
await resend.emails.send({
from: 'sign@signnee.online',
to: signerEmail,
subject: `Please sign: ${documentTitle}`,
react: SigningRequestEmail({
signerName,
documentTitle,
signingUrl: `https://signnee.online/sign/${token}`,
expiresIn: '7 days',
}),
})
// Attach completed PDF
await resend.emails.send({
from: 'sign@signnee.online',
to: [senderEmail, signerEmail],
subject: `Signed: ${documentTitle}`,
attachments: [{ filename: 'signed.pdf', content: pdfBuffer }],
})
| Package | Purpose | Weekly Downloads | Difficulty |
|---|---|---|---|
| pdfjs-dist | Render PDF pages as canvas for field placement UI | ~4M/wk | Hard — complex API |
| pdf-lib | Embed signatures + text into final PDF, flatten document | ~800K/wk | Medium — good docs |
| signature_pad | Canvas-based signature drawing (mouse + touch) | ~200K/wk | Easy — drop in |
| @dnd-kit/core | Drag-and-drop for placing fields on PDF canvas | ~1.5M/wk | Medium |
| jose | Sign/verify JWT tokens for signer portal URLs | ~5M/wk | Easy |
| nanoid | Generate unique submission and document IDs | ~10M/wk | Easy — one-liner |
| resend | Transactional emails (signing requests, completions) | ~300K/wk | Easy |
| react-email | Build HTML email templates in React | ~200K/wk | Easy |
| sharp | Resize/optimize signature images before embedding | ~5M/wk | Easy |
| date-fns | Expiry dates, timestamps, audit trail formatting | ~8M/wk | Easy |
| Table | Key Columns | Purpose |
|---|---|---|
| sn_documents | id, user_id, title, original_pdf_url, signed_pdf_url, status, created_at | Master document record |
| sn_fields | document_id, type, page, x, y, width, height, required, signer_role | Field positions on each PDF page (as % coordinates) |
| sn_signers | document_id, email, name, role, order, token, signed_at, status | Each person who needs to sign + their token |
| sn_field_values | field_id, signer_id, value_type, value_text, value_image_url, signed_at | What each signer entered/drew for each field |
| sn_audit_log | document_id, signer_id, event, ip_address, user_agent, timestamp, prev_hash, hash | Tamper-evident chain of all events |
| sn_templates | user_id, name, original_pdf_url, fields_json, thumbnail_url | Reusable document templates with pre-placed fields |
| sn_webhooks | user_id, event_type, target_url, secret, active | Per-user n8n/Make.com endpoint configs |
| sn_subscriptions | user_id, stripe_sub_id, plan, docs_used, docs_limit | Plan limits + Stripe billing |
For e-signatures to be legally binding in the US, you must satisfy these requirements. DocuSeal handles all of these. Building from scratch means you implement each one.
Signer must affirmatively check "I agree to sign electronically" before accessing the document. Store this consent with timestamp.
consent_given_at TIMESTAMPTZ
Signer must verify via email click (token in URL). The email address ties the signature to an identity. Store IP + user agent.
ip_address, user_agent, email_verified_at
After signing, the PDF must be flattened so fields cannot be moved. Store SHA-256 hash of the final PDF for verification.
signed_pdf_hash VARCHAR(64)
Every action must be logged: opened, signed, IP address, timestamp. Must be stored separately from the document and tamper-evident.
sn_audit_log with hash chaining
Signed documents must be accessible for the life of the agreement. Store original + signed PDF in durable storage (Supabase Storage / S3).
Never delete signed documents
Every signer must receive a copy of the completed signed PDF. Email it as an attachment when the final signature is collected.
Email all parties on document.completed
The hardest week. Get pdfjs-dist rendering reliably, build the field drag-and-drop system, store field coordinates as percentages.
Build the no-auth signing page. JWT token system. signature_pad integration. Typed + drawn + uploaded signature modes.
Embed signatures into PDF using pdf-lib. Flatten document. Build tamper-evident audit log with hash chaining. Legal consent flow.
Resend email system, webhook relay to n8n/Make, main dashboard UI, templates system, Stripe subscription billing.
| Feature | DocuSeal (self-hosted) | Build From Scratch | Effort if Scratch |
|---|---|---|---|
| PDF rendering | ✓ Done | You build | 4–5 days |
| Field placement UI | ✓ Done | You build | 3–4 days |
| Signature capture | ✓ Done | signature_pad (easy) | 1–2 days |
| PDF finalization | ✓ Done | pdf-lib (complex) | 3–4 days |
| Signer portal | ✓ Done | JWT + no-auth page | 2 days |
| Multi-signer + order | ✓ Done | State machine | 2 days |
| Audit trail | ✓ Done (legal) | Hash chain logic | 2 days |
| Email notifications | ✓ Done | Resend (easy) | 1 day |
| Webhooks | ✓ Built-in | Custom relay | 1 day |
| REST API | ✓ Full API | You build all routes | 2–3 days |
| Templates | ✓ Done | Template storage system | 1–2 days |
| Mobile signature UX | ✓ Tested | Tricky touch handling | 2–3 days extra |
| Embedded iframe signing | ✓ Built-in | CORS + security config | 1 day |
| Branded dashboard | You build on top | You build (same effort) | Same either way |
| Stripe billing | You build | You build | Same either way |
DocuSeal handles ~80% of the hardest work (PDF engine, signing UI, legal compliance). Building from scratch is absolutely doable — but it's 3–4 weeks vs 4–5 days, with most of that time on problems DocuSeal already solved years ago.
White-label DocuSeal. Launch Signnee in a week. Get paying clients. The money validates the idea before spending weeks building the engine.
If Signnee hits 50+ clients and $2K+ MRR, spend a sprint migrating to the scratch-built engine. Revenue funds the rebuild. No risk taken upfront.