How to build a DocSend alternative

How to build a DocSend alternative with a private PDF, a gated link, and a view count. Follow the prompts, check each step, then skip the data room.

  • guide
  • vibe-coding
  • docsend

You can learn how to build a DocSend alternative as a PDF behind a gated link, plus a number that moves when someone opens it. DocSend is that product. The catalog line is “A document, a secure link, and enough viewing data.”

You pay $65–$180 a month. That is $780–$2,160 a year. The verdict is “Huge savings candidate”. The first estimate is 4 hours: upload a PDF, gate the link, count the opens.

I already wrote the teardown, Vibe code your own DocSend. This guide is the walk. Prompts in order, then what to look at.

The audience is a founder sharing pitch decks and sales documents. If you need e-signatures, a virtual data room, or a CRM sync, keep paying. The same v1 also replaces PandaDoc. If you use PandaDoc for signatures, that is the skip row.

A file they should save is a different job. That is Dropbox Transfer, $20–$32 a month, about 3 hours. Build Transfer if they download a zip. Build this if they read it in the browser, and you want to know they did.

How to build a DocSend alternative without a data room

The DocSend page splits the work into three rows.

The first row is the one you want. A PDF in private storage. A share link with an email field. A viewer that never puts the file URL in the address bar. A counter that goes up when a session starts. That is the 4-hour v1.

The second row is 2 days. Passcodes, page-level analytics, expiry, a CSV export. Do that after you have sent one real deck.

The third row is a month and up. E-signatures, virtual data rooms, CRM sync. Skip it.

If you tell an agent to “clone DocSend”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. 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,
}

const session = {
  id: 'ses-19',
  linkId: 'lnk-acme',
  claimedEmail: '[email protected]',
  startedAt: '2026-10-26T14:00:00.000Z',
}

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.

The card lists Document, ShareLink, ViewerSession, and PageView. Tonight you need the first three. A single open event is enough. PageView is the 2-day row.

documents.id is stable. share_links.slug is unique. viewer_sessions belong to one link. opens lives on the link row, not on the document, so Acme and Northwind never share a counter.

DOCUMENT Series A deck, 14 pages docs/doc-41/original.pdf Private object, never public SHARE LINKS acme-deck and northwind-deck unique slug, own open count revoke one, the other stays VIEWER SESSION [email protected], claimed Starts after the email gate Dead if the link is revoked
The file is an object. The link and the session are rows. They never share a counter.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for DocSend is Astro, R2, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. R2 is there because a 14-page deck does not fit in a table row. D1 holds the document, the links, and the sessions. Do not invent a second product to host the PDF.

Open Build your own DocSend. The guardrail is the useful sentence: state clearly that viewer analytics are approximate, and do not fingerprint devices. The non-goal sits next to it. No e-signatures, virtual data rooms, CRM sync, or per-slide heatmaps.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. Pick one stack and stay on it.

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 4-hour row only. PDF upload,
gated link, open tracking.

Project name: Prooflink.
Public URL is https://send.flaviocopes.com.
Owner is Flavio. Prospects do not create
accounts.

Seed document: Series A deck,
series-a-deck.pdf, 14 pages.

Do not add passcodes, page analytics,
expiry, or a CSV export yet.
Do not add e-signatures, a data room,
CRM sync, or per-slide heatmaps.

When the plan is done, stop and wait. Do
not write implementation code until I say
go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /library for uploaded decks
  • /library/doc-41 for link settings
  • /acme-deck for the public gate and viewer
  • /library/doc-41/activity can wait

You want a documents table and a share_links table. viewer_sessions can wait one prompt. page_views should not appear. rooms should not appear. A signatures table should not appear.

If the plan includes a Salesforce connector, a signing ceremony, or a public R2 bucket, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see an empty library and an upload action.

Open the migrations. documents.id is stable. share_links.slug is unique. share_links point at one document. There is no public_url column on the document.

If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say the original PDF stays in private object storage, and every page request checks an active link plus a session.

Prompt 2. Store the PDF where nobody can guess it

Agents will open an R2 bucket as public and drop the object URL into an iframe. Anyone with the network tab then has the file forever, even after you revoke the link.

The upload is a private write. Title and page count come back. The object key is not a URL you can paste.

Build the document library for Prooflink
on https://send.flaviocopes.com.

Accept one PDF. application/pdf only.
Reject a renamed .zip. Count pages on
upload. Title defaults to the filename
minus .pdf. Flavio can edit the title
after.

Store the original at
docs/{documentId}/original.pdf in a
private R2 bucket. Do not enable a
public r2.dev hostname. Do not write
an object URL into the document row.

Library rows show title, page count,
created date, and active-link count.
Rename and archive. Delete waits.

Seed Series A deck as 14 pages if the
local file is a fixture. Keep the same
storageKey shape.

Optional branding is a logo URL and a
hex color on the document row, shown
later in the viewer header. No upload
for the logo tonight.

Do not add a second file type.
Do not add folders or a data room.

Done when I can upload series-a-deck.pdf,
see Series A deck and 14 pages after
reload, and the storage key is not
fetchable as a public URL.
function acceptUpload(file) {
  if (file.type !== 'application/pdf') return null
  return file
}

acceptUpload({
  name: 'series-a-deck.pdf',
  type: 'application/pdf',
  size: 1843200,
})

A renamed zip with a .pdf suffix is how a deck becomes a malware host. Check the type, then open the file as a PDF before you store it. If the agent wants a multi-gigabyte drop zone, it is building Transfer.

Keep the original. Do not transcode it into images on the way in. Page images, if you ever add them, are a cache behind the same grant check.

What to check after the upload lands

Open /library. Upload series-a-deck.pdf. Reload. The row still says Series A deck and 14 pages.

Open the document row in D1. storageKey looks like docs/doc-41/original.pdf. There is no https:// in that field.

Guess https://send.flaviocopes.com/docs/doc-41/original.pdf in a private window. 404. Guess any r2.dev URL the agent printed in the logs. It should not resolve as public.

If the agent added a folder tree, delete it. The capability line is one document, several links.

The first journey on the card creates two share links for two prospects. Do that at 4 hours if you send the same deck twice. Each link gets its own slug and its own counter.

Tonight the gate is 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 later mini-prompt 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.

Build share links for Prooflink on
https://send.flaviocopes.com.

One document, several named links.
Each link has an immutable id, a unique
slug, a name, requireEmail, revokedAt,
and an opens counter that starts at 0.

Create two links on Series A deck:
- Acme / acme-deck / email required
- Northwind / northwind-deck / email
  required

Public URL is
https://send.flaviocopes.com/{slug}.

The gate asks for an email. Normalize
it (trim, lowercase) and store it as
claimed identity. Increment opens when
the session row is inserted, in the
same write.

A revoked or missing slug returns
"This link is unavailable." No title.
No logo. No hint that the slug exists.

Rate-limit attempts per slug so a bot
cannot farm sessions.

Revoke sets revokedAt and blocks new
sessions. Historical opens stay on
that row. Duplicate copies the gate
flags into a fresh id with zero opens.

Do not add a passcode. Do not add
expiry. Do not add page analytics.

Done when [email protected] opens acme-deck,
the counter is 1, Northwind is still 0,
and revoking Acme blocks a second tab
while Northwind still works.

The two counters never share a row. The mini-prompt is “Revoke one link, keep the receipts”.

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]',
})

Write the session and the increment together. A retry that creates two sessions should still add two opens, because two people can share a laptop. A retry that crashes after the insert should not invent a third open for the same session id.

What to check after Alex types an email

Open https://send.flaviocopes.com/acme-deck in a private window. Submit [email protected]. You pass the gate. /library/doc-41 shows Acme at 1 open and Northwind at 0.

Submit [email protected] in a second private window. The stored claim is [email protected] both times.

Revoke Acme. Refresh the first tab, or open a third window on the same slug. Generic error. No Series A title. Northwind still opens.

Duplicate Acme. The new link has the email flag and zero opens. The old Acme row still shows its count.

Guess /missing-deck. Same generic error as the revoked slug.

Prompt 4. Serve pages through a short-lived grant

The viewer is why this is not Transfer. They read in the browser. You never hand them the file.

The mini-prompt on the card is “The PDF viewer that never leaks the file URL”. 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.

Build the viewer for Prooflink on
https://send.flaviocopes.com/acme-deck.

After the gate, show a branded header,
page canvas, prev/next, and a page
number. No download button. No print
stylesheet. No raw object URL in the
address bar or in an iframe src that
points at R2.

Every page request hits an endpoint
that checks:
- the session belongs to this link
- the link is not revoked
Then it signs a delivery URL for one
page (or one byte range) that expires
in 60 seconds. Scope the grant to
doc-41. Never sign the whole file.

Revoke invalidates outstanding grants
in the same write as revokedAt.

Locally, print the grant id and the
expiry. Do not pretend a public bucket
is easier.

Done when I can read page 3 as Alex,
paste that signed URL into a private
window after 60 seconds and see it
fail, revoke Acme, and watch the next
page turn fail in the open tab.

Paste any of those signed URLs into a private window after the TTL. It should fail.

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,
})

The original object is never publicly addressable. The iframe, if you use one, points at your Worker, not at R2.

What to check after page 3 loads

Open Acme as Alex. Turn to page 3. The address bar still says /acme-deck. View source. There is no r2.dev and no storageKey in the HTML.

Copy a signed URL from the network tab. Wait a minute. Paste it in a private window. It fails.

Revoke Acme while the tab is still open. Click next. The page does not load. The generic error is enough. Do not show “this deck was revoked by Flavio”.

Upload a second PDF. A grant minted for doc-41 cannot fetch the second file.

If the agent put a Download button next to the page number, remove it. That is Transfer leaking in through the toolbar.

Failures the agent ships anyway

A public object URL in the iframe

iframe src="https://pub-….r2.dev/docs/doc-41/original.pdf" is a download with extra CSS. Store private. Grant per page. The hosting post says the same thing: put the file through the Worker. Do not paste an r2.dev link into the email.

One grant for the whole file

A 60-second URL that returns all 14 pages is still the whole deck on disk. Anyone who saved it keeps it. Sign a page or a byte range.

Revoke that leaves old signatures valid

If you mark revokedAt and leave grants valid for 60 seconds, a tab that already loaded page 1 can walk the rest of the deck. Invalidate grants in the same write. The next page turn fails immediately.

Email treated as a login

claimedEmail is a string the visitor typed. It is not proof they own the inbox. Do not mail them a magic link tonight. Do not show other visitors that address. The 2-day row can add a hashed passcode if you still want a shared secret.

“Series A deck is no longer available” tells a leaked slug that the file exists. Use the generic sentence. Missing, revoked, and malformed slugs look the same.

A document-level opens field is how Northwind inherits Acme’s traffic. The count lives on the link. Duplicate starts at zero. Revoke keeps the old receipts.

Page heatmaps in the first afternoon

The guardrail says analytics are approximate. Fingerprinting is out. Page-by-page duration is the 2-day row, and even then you label every number approximate. Tonight the number is “this link was opened”.

If the agent starts a Salesforce connector or a signing ceremony, paste the non-goal and delete it. CRM sync belongs on HubSpot CRM, $20–$150 a person a month, about 4 days.

Deploy the gated viewer

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Prooflink wants Astro, R2, and D1. Put it on a subdomain you already own, send.flaviocopes.com. The Astro prompt points at Cloudflare D1 for the tables, R2 for the object, and Workers for the grant endpoint. The hosting post is that path: the adapter, then the bindings, then the custom domain.

The catalog lists email as an integration. You do not need to mail the PDF. Print the session id in development.

Attach the domain on the Worker. send.flaviocopes.com/acme-deck has to resolve on the public internet, not only on localhost. Open it on a phone on cellular.

Ship with the seed deck. Create Acme and Northwind. Open Acme as Alex. Confirm the count. Revoke Acme. Confirm Northwind still works. Paste a grant URL after a minute and watch it die.

Take a D1 backup, and download a copy of the R2 object, before you delete the paid account.

Use it as the real send for a week, not as a demo. If you miss a passcode or a CSV, those are the 2-day row. Add them after a stranger has already opened the deck.

Then cancel DocSend, or PandaDoc, whichever one you pay for.

What to skip on purpose

Leave out e-signatures, a data room with folders, CRM sync, and anything that looks like a per-slide heatmap.

4-HOUR V1 Private PDF in object storage Email-gated share links Opens counted per link SKIP THIS HALF E-signatures Virtual data room CRM sync
Ship the 4-hour viewer. Leave the data room on the skip row.

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. The 2-day mini-prompt for read time is worth copying later: a page accumulates time only while it is current and the tab is visible.

A short URL is a different job. That is Bitly, $35–$300 a month, about 90 minutes. The link shortener guide is that walk. You already have a slug on send.flaviocopes.com. You do not need a second shortener in front of it.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own DocSend. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: PDF upload, gated link, open tracking. Four hours.

Publish send.flaviocopes.com/acme-deck. Send one real deck through it tonight.