Vibe code your own DocSend
Vibe code your own DocSend as a PDF, a gated link, and a view count. Send a deck you own and stop paying $65–$180 a month for a passworded link.
You can vibe code your own DocSend as a PDF behind a link. DocSend is that product. The catalog line is “A document, a secure link, and enough viewing data.”
You pay $65–$180 a month for it. That is $780–$2,160 a year. The basic replacement is 4 hours: upload a PDF, gate the link, count the opens.
I would ship this if I still rented a viewer for pitch decks. It is marked Tonight. The verdict on the card is “Huge savings candidate”.
Yesterday I wrote about how Figma works. That was a teardown. Today we are back on a regular backlog topic, the charge that hurts once you have shipped one small tool.
What you get when you vibe code your own DocSend
The DocSend page splits the work into three rows.
The first row is the one you want tonight. PDF upload, a gated link, open tracking.
The second row is 2 days. Passcodes, page-level analytics, expiry, a CSV export. Those can wait until you have sent the first deck.
The third row is a month and up. E-signatures, virtual data rooms, CRM sync. The table labels that row as the part you should skip.
If you tell an agent to “clone DocSend”, it aims at the third row. Use the prompt on the page. It is written for a useful v1, and you still have to keep it on the first row.
Here is the shape of the data you are building. Nothing else has to exist at 4 hours.
const document = {
id: 'doc-41',
title: 'Series A deck',
filename: 'series-a-deck.pdf',
pageCount: 14,
storageKey: 'docs/doc-41/original.pdf',
}
const link = {
id: 'lnk-acme',
documentId: 'doc-41',
name: 'Acme',
slug: 'acme-deck',
requireEmail: true,
revokedAt: null,
opens: 0,
}
A visitor hits https://send.flaviocopes.com/acme-deck, types an email, and reads the deck in the browser. You never mail them a public object URL.
Who a DocSend alternative is for
The audience line on the page is specific. A founder sharing pitch decks and sales documents.
The goal is to share a PDF with access controls and see whether recipients opened it. Tonight the done state is one live gated URL and a count that moves when you open it from a private window.
If you need e-signatures, a virtual data room, or a sync into a CRM, keep paying. That is the honest rebuild. The site puts it at a month on purpose.
The same v1 also replaces PandaDoc. One focused build covers whichever of those you currently pay for. If you use PandaDoc for signatures, that is the skip row, not tonight.
A file you only need downloaded is a different job. That is Dropbox Transfer, $20–$32 a month, about 3 hours. Build Transfer if they should save a zip. Build this if they should read it in the browser, and you want to know they did.
Read the non-goals before you copy anything
Open the guide. Read the audience and the non-goals before you touch the prompt.
The non-goal is the useful sentence: do not add e-signatures, virtual data rooms, CRM sync, or per-slide heatmaps.
The guardrail sits next to it. State clearly that viewer analytics are approximate. Do not fingerprint devices.
Then pick the stack you already write. Every prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The AHA version for this product uses Astro, R2, and D1. This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language. The scope matters more than the framework.
Copy the prompt. Before you run it, add one extra line at the top: ship the 4-hour row only. PDF upload, gated link, open tracking. Stop.
Walk the first DocSend journey
You upload series-a-deck.pdf. The app confirms 14 pages and the title. You add a small logo in the viewer header. You create two share links, one named Acme and one named Northwind, each with its own slug.
A prospect opens the Acme URL. They type an email to get in. A viewer session starts. They read a few pages in the browser, leave, and later resume through the same link.
You look at the two links. Acme has an open. Northwind does not. You copy the Acme URL again, or you revoke it if that thread died.
The screens the prompt asks for are a document library, link settings, a secure PDF viewer, and an analytics page. For 4 hours you need the library, one link form, and the viewer. The library is title, page count, active-link count, recent opens. The form is link name, email gate, copy URL, revoke. The viewer is a branded header, pages, and no public file URL in the address bar. A page-by-page timeline waits.
The data model on the page is Document, ShareLink, ViewerSession, PageView. Tonight you need the first three. A single open event is enough. PageView is the 2-day row.
Code the viewer so the file URL never leaks
Agents will upload the PDF to a public bucket and drop the object URL into an iframe, which is a download with extra CSS. Anyone with the network tab has the file forever, even after you revoke the link.
The mini-prompt on the page is “The PDF viewer that never leaks the file URL”. Store the original in private object storage. The viewer asks for one page, or one byte range, at a time. The endpoint checks two things on every request: the share link is still active, and this caller holds a valid session for that link. If both pass, mint a signed delivery URL that expires in about 60 seconds. Never mint a grant for the whole file.
A link revoked mid-session must fail the next page request, not only future sessions. A grant is scoped to one document, so a leaked URL cannot fetch a sibling deck.
async function grantPage(db, { linkId, sessionId, page }) {
const session = await db.getSession(sessionId)
const link = await db.getLink(linkId)
if (!session || session.linkId !== linkId) return null
if (link.revokedAt) return null
return signDelivery({
storageKey: `docs/${link.documentId}/original.pdf`,
page,
expiresInSeconds: 60,
})
}
await grantPage(db, {
linkId: 'lnk-acme',
sessionId: 'ses-19',
page: 3,
})
Paste any of those signed URLs into a private window after the TTL. It should fail. Revoke lnk-acme and the next page turn should fail too, even if the tab was already open. The original object is never publicly addressable.
The email gate is claimed identity
Tonight a gate can be an email field. Passcodes wait for the 2-day row. The email is what the visitor typed, not a verified inbox. Trim and lowercase it so [email protected] and [email protected] are one claim on the session.
The mini-prompt for later passcode work is “The passcode gate that fails without leaking”. Steal one rule from it tonight. A revoked link must not create a session, and the error page must not expose the document title, the owner, or the company branding. A dead link and a missing email return the same generic message.
async function openGate(db, { slug, email }) {
const link = await db.getLinkBySlug(slug)
const generic = 'This link is unavailable.'
if (!link || link.revokedAt) {
return { ok: false, error: generic }
}
const session = await db.createSession({
linkId: link.id,
claimedEmail: email.trim().toLowerCase(),
startedAt: new Date().toISOString(),
})
await db.incrementOpens(link.id)
return { ok: true, sessionId: session.id }
}
await openGate(db, {
slug: 'acme-deck',
email: '[email protected]',
})
Rate-limit attempts per link even without a passcode, so a bot cannot farm sessions. Hash passcodes later. The database should never contain a plaintext one.
Two links, two counters
The first journey creates separate share links for two prospects. Do that at 4 hours if you send the same deck twice. Each link gets an immutable identifier. Revoking Acme blocks new sessions on Acme. Northwind keeps working. The two counters never share a row.
The mini-prompt is “Revoke one link, keep the receipts”. Historical opens stay attached to the identifier you revoked. Duplicating a link copies the gate settings into a fresh identifier with zero opens, so you can rotate a URL that leaked in Slack without retyping the form.
async function revokeLink(db, linkId) {
await db.setRevokedAt(linkId, new Date().toISOString())
await db.invalidateGrants(linkId)
}
await revokeLink(db, 'lnk-acme')
Invalidate grants in the same write. If you mark the row revoked and leave old signatures valid for 60 seconds, a tab that already loaded page 1 can still walk the rest of the deck.
What not to build in a DocSend clone
Leave out e-signatures, a data room with folders, CRM sync, and anything that looks like a per-slide heatmap.
Passcodes are on the feature list. Skip them until the email gate has been used by a real prospect. Expiry waits. CSV waits. Page-by-page duration waits.
If the agent starts a Salesforce connector or a signing ceremony, stop it. Paste the non-goal again. CRM sync belongs on HubSpot CRM, $20–$150 a person a month, about 4 days.
The 2-day row, after you have sent a deck
Use the viewer as the real tool for a week. Then look at the second estimate.
Passcodes, hashed, with the fail-closed error page from the mini-prompt. Five rapid wrong attempts trip a rate limit. A correct passcode on an expired link still yields no session and no metadata.
Expiry is an absolute timestamp. Check it on the gate and on every grant. A session that started before expiry does not get new pages after the clock passes.
Page-level reading analytics. The mini-prompt is “Read-time analytics from a tab you can’t trust”. The client emits page turns, visibility changes, focus and blur. A page accumulates time only while it is the current page and the tab is visible. If no event arrives for 60 seconds, stop attributing time, so a tab left open overnight does not record nine hours on page 3. Pages 1, 2, and 5 should show exactly that sequence. A repeat visit to page 1 is a second entry, not a merge. Label every number approximate. Never fingerprint the device.
function pageSeconds(events, idleCutoff = 60) {
const visits = []
for (let i = 0; i < events.length; i++) {
const current = events[i]
if (current.type !== 'page') continue
const next = events[i + 1]
const end = next ? next.at : current.at + idleCutoff * 1000
let seconds = (end - current.at) / 1000
if (!current.visible) seconds = 0
if (seconds > idleCutoff) seconds = idleCutoff
visits.push({ page: current.page, seconds })
}
return visits
}
const events = [
{ type: 'page', page: 1, at: 0, visible: true },
{ type: 'page', page: 2, at: 12000, visible: true },
{ type: 'page', page: 5, at: 40000, visible: true },
{ type: 'hide', at: 55000 },
]
pageSeconds(events)
//[{ page: 1, seconds: 12 }, { page: 2, seconds: 28 }, { page: 5, seconds: 15 }]
Totals across pages must not exceed wall-clock session length. If they do, your idle cutoff is not firing.
CSV export of that timeline, filtered by link. Escape spreadsheet formulas on the way out.
That is the solid v1. The catalog says 2 days. Stop there.
The month-long row is e-signatures, data rooms, and CRM sync. You do not need that.
Host it, send the next deck, cancel
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Put the viewer on a subdomain you already own, send.flaviocopes.com. Send the next deck from that domain. Leave the old DocSend URL in old threads if you must. New threads get the link you host.
Use it for a week as the real tool, not as a demo. If you miss a passcode or a CSV, add them from the 2-day row. You will not miss a data room.
Then cancel DocSend, or PandaDoc, whichever one you pay for.
A short URL is a different job. That is Bitly, $35–$300 a month, about 90 minutes. You already have that post if redirects are the rent.
If you want another Tonight card after this one, the audit is still the index. TinyPNG Pro is about 60 minutes and stays in the browser.
Start now
Go to Build your own DocSend. Copy the AHA prompt, or the stack you already write.
Tell the agent the first estimate only: PDF upload, gated link, open tracking. Four hours.
Publish the link. Send one real deck through it tonight.