How to build a file transfer page

How to build a file transfer page with an upload, an expiry, and a signed download link. Follow the prompts, check each step, then skip file sync.

  • guide
  • vibe-coding
  • dropbox-transfer

You can learn how to build a file transfer page as an upload, an expiry date, and a signed download link. Dropbox Transfer is that product. The catalog line is “Upload files, send one expiring link, move on.”

You pay $20–$32 a month. The card says that price scales with the team. That is $240–$384 a year at one seat. The verdict is “Simple and useful”. The first estimate is 3 hours: upload files, expiring download link.

There is no standalone Transfer teardown. The Dropbox post already splits a private library from a one-shot package. The DocSend post is the cousin that stays in the browser. This guide is the walk. Prompts in order, then what to look at.

The audience is a studio sending large deliverables to one client. If you need a folder tree, desktop sync, or a preview for every format, keep paying. The same v1 also replaces WeTransfer and Smash.

A library you keep is Dropbox, $9.99–$24 a person a month, about 8 hours. A PDF they should read, not save, is DocSend, $65–$180 a month, about 4 hours. I walked that as How to build a DocSend alternative. A short slug in front of a URL is Bitly, $35–$300 a month, about 90 minutes. That walkthrough is Build your own link shortener step by step.

How to build a file transfer page without a sync client

The Dropbox Transfer page splits the work into three rows.

The first row is the one you want. A named package. A few files that finish uploading. An expiry. A recipient page that hands out a short-lived signed URL. That is the 3-hour v1.

The second row is 10 hours. Passcodes, branding, download tracking, automatic cleanup. Do that after one real client has already saved the zip.

The third row is a month and up. File sync, shared folders, previews for everything. Skip it. That is the other Dropbox.

If you tell an agent to “clone Dropbox Transfer”, it aims at the third row, or it starts building Filebay. Pin every prompt to the first estimate.

v1 does this. Flavio opens Parcel and names a transfer Rome JS launch kit. He drops brand-guide.pdf and logo.png. Both finish. He sets expiry to 7 November 2026. He publishes. The link is https://drop.flaviocopes.com/t/rome-js-launch. Ada Rossi opens it, sees two filenames and sizes, and downloads the PDF. After expiry, or after Flavio hits revoke, the next signed URL is refused. Passcodes, a logo bar, a download timeline, and a nightly delete job stay off the page.

The parts and where a request goes

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Transfer is Astro, R2, and Workers. Metadata still needs a database. D1 is the one I use. 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. Pick one stack and stay on it.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the sync client waits.

Open Build your own Dropbox Transfer. The guardrail is the useful sentence: use signed download URLs, hash passcodes, and delete expired objects automatically. Tonight you keep the signed URL. Passcode hashing and the delete job wait for 10 hours. The non-goal sits next to it. No file synchronization, shared folders, previews for every format, or permanent storage.

FLAVIO'S BROWSER Composer, list login required ADA'S BROWSER Download page no account ASTRO APP ON THE REQUEST Upload, publish, revoke Check expiry, then sign D1 HOLDS ROWS Transfer, files status and expiry R2 HOLDS BYTES Private objects never a public URL AFTER PUBLISH COMMITS Email Ada. Copy still works. SIGNED URL, MINUTES ONLY One file, one grant Recheck state before each mint CLEANUP JOB 10-hour row. Expiry still wins now.
Upload writes a draft. Publish is what mints a public slug. Every download rechecks expiry.

Upload, publish, revoke, and download are ordinary form posts. The email starts after the transfer is active. If mail is down, Flavio still copies the link and Ada still downloads. The signed URL is not a public bucket path. If the plan invents a desktop client, a folder tree, or a nightly preview renderer, send it back.

The data the transfer carries

The card lists Transfer, TransferFile, and DownloadEvent. Tonight you need the first two. DownloadEvent is the 10-hour row.

A transfer has a name, an optional message, a public slug, an expiry timestamp, and a status. Status is draft, uploading, active, expired, or revoked. Deleted waits for the cleanup job.

A file belongs to that transfer. It carries the original filename, a size, a content type you verified, an R2 key you issued, and a complete flag. A transfer with any incomplete file cannot become active. Publish stays blocked until Flavio retries the file or removes it.

The slug is unique. rome-js-launch is what Ada types. The internal id stays off the public URL. Bytes live under keys you generated, never under Ada’s original path.

TRANSFER rome-js-launch · 7 Nov TRANSFER FILE brand-guide.pdf · complete BYTES IN R2 our key, not hers PUBLIC SLUG only when active DOWNLOAD EVENT 10-hour row PASSCODE 10-hour row NO FOLDER TREE no preview renderer either
One transfer, many files, one slug. Events and passcodes wait. Folders never arrive.

Every owner query is scoped to Flavio. A guessed transfer id from a private window returns nothing useful: no filenames, no sizes, no 404 that confirms the id. The recipient page is the only anonymous surface, and only while the transfer is active.

The screens you ship

Three surfaces. Keep them boxes and labels. If the agent starts matching Dropbox Transfer’s live UI, send it back. Branding and a download timeline wait for 10 hours, so a logo picker is not a screen tonight.

The composer is Flavio’s product. Transfer name. Optional message for Ada. Expiry picker. A drop zone. Per-file progress and size. A publish button that stays off until every file is complete. No public link exists on this page.

WIREFRAME · COMPOSER Rome JS launch kit Homepage files for Ada Expires 7 Nov 2026 brand-guide.pdf 2.4 MB done logo.png 180 KB done PUBLISH Publish stays off if a file failed
Name, files, expiry, publish. The recipient slug does not exist yet.

The dashboard lists Flavio’s transfers. Active, expired, and revoked sit as states on the row. Each row shows the name, file count, expiry, and a copy-link action. Revoke sits on an active row. Duplicate can wait. A download count waits for 10 hours.

WIREFRAME · DASHBOARD Rome JS launch kit · ACTIVE 2 files · expires 7 Nov COPY LINK REVOKE Northwind photos · EXPIRED 4 files · ended 12 Oct
Active is the only row that copies a live link. Revoke is a click.

The recipient page is what Ada sees. Transfer name. The message. Filenames and sizes. One download button per file. No login. No owner email. If the transfer is expired, revoked, or the slug is junk, she sees the same generic sentence.

WIREFRAME · DOWNLOAD Rome JS launch kit Homepage files for Ada brand-guide.pdf 2.4 MB GET logo.png 180 KB GET No account. No owner email.
Filenames and a button. The address bar never holds an R2 URL.

Upload, publish, and download

The first journey on the card is this walk. Create the package. Finish every file. Publish. Ada saves a copy. The clock, or revoke, kills the next grant.

NAME THE PACKAGE DROP THE FILES EVERY FILE COMPLETE? no: publish stays off SET EXPIRY, PUBLISH SLUG GOES LIVE copy, then optional email ADA OPENS THE PAGE STILL ACTIVE? no: generic dead page MINT A SHORT URL one file, then recheck REVOKE KILLS THE NEXT GET
Complete files first. Publish is what shares. Expiry is a read-time check.

A gallery with a pin is a different job. That is Pixieset, $10–$50 a month, about 8 hours. A document they should not download is the DocSend guide.

Prompt 1. Scaffold Parcel and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it. If you picked TanStack Start, keep the same routes and rules. Only the adapters change.

Ship the 3-hour row only.
Upload files, expiring
download link.

Project name: Parcel.
Public URL is
https://drop.flaviocopes.com.
Owner is Flavio.

Seed transfer slug:
rome-js-launch
Name: Rome JS launch kit
Message: Homepage files for Ada
Expiry: 2026-11-07

Do not add passcodes, branding,
download tracking, or auto
cleanup.
Do not add file sync, shared
folders, previews for every
format, or permanent storage.

When the plan is done, stop.
Do not write 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 /new for the composer, /transfers for the dashboard, /t/rome-js-launch for Ada, plus a download action that mints a signed URL. Tables for transfers and files. R2 for bytes. No folders, no previews, no passcodes.

If the plan includes a desktop client, a ZIP streaming engine, or a Stripe meter, send it back. When the plan is narrow, say go.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see an empty dashboard and a button to create a transfer.

Open the migrations. Transfers store status and an expiry timestamp. Files store a complete flag and an R2 key. There is no path from draft to /t/rome-js-launch without a publish write.

If the README is a placeholder, stop. It has to name the complete-files rule, the signed URL, the read-time expiry check, and the generic dead page.

Prompt 2. Upload into a draft

The composer is the intake. A failed file is not a published package.

Build the Parcel composer at
/new.

Flavio types a name, an optional
message, and an expiry. He drops
one or more files. Each file
shows progress and a size.

Cap one file at 100 MB and the
whole package at 500 MB. Those
are v1 limits, not a vendor
quote. Check the decoded type.
Allow pdf, png, jpg, zip, and
mp4. Reject html, js, and exe.

Store bytes in R2 after the
checks. The stored key is yours.
The filename Ada sees is the
original name with path parts
stripped.

A transfer stays draft or
uploading until every file is
complete. Publish is disabled
while any file is missing,
failed, or still writing.

Do not create a public slug
from this page.
Do not email Ada from here.
Do not add a passcode field.

Done when Flavio can drop
brand-guide.pdf and logo.png,
see both marked complete, and
a failed third file keeps
publish off until he removes it.

What to check after the files land

Sign in. Create Rome JS launch kit. Drop the PDF and the PNG. Both show done. Publish is available.

Drop a .html file. It is rejected. No R2 object. No file row that looks complete.

Start a third file and pull the plug. The row is incomplete. Publish stays off. /t/rome-js-launch still 404s as the generic dead page, or does not exist yet.

Open /new with no cookie. Login wall.

Publish is the only write that creates a public slug. The dashboard is where Flavio copies it and later kills it.

Build publish and the Parcel
dashboard at /transfers.

Publish is allowed only when
every file is complete and
expiry is in the future.
The write sets status to
active and assigns the slug
rome-js-launch.

Dashboard rows show name,
file count, expiry, and
status: active, expired,
revoked. Copy link sits on
active rows. Revoke sits on
active rows.

Revoke is a human write.
It stops new signed URLs
immediately. Be honest that
a download already streaming
on a minted URL may finish
until that URL expires.

Login required. A guessed
transfer id from a signed-out
window returns nothing
useful. No recipient email
on this page tonight.

Do not add download counts.
Do not add branding.
Do not add a ZIP of everything.

Done when publish creates
/t/rome-js-launch, copy
puts that URL on the
clipboard, and revoke makes
the next open show the
generic dead page.

What to check after you publish

Publish the kit. Dashboard shows active, 2 files, 7 Nov. Copy the link. It is https://drop.flaviocopes.com/t/rome-js-launch, or the localhost equivalent.

Open the slug in a private window. Ada sees the name, the message, and two files. She does not see Flavio’s login.

Revoke. Refresh the private window. Generic dead page. The sentence is the same for a guessed slug.

Create a second transfer and leave it as a draft. It has no public slug. Guessing an id from a signed-out window returns nothing useful.

Prompt 4. Download through a signed URL

The first mini-prompt on the card about revoke is the useful one. Every click rechecks state, then mints minutes of access.

Build the recipient page at
/t/:slug and the download
action.

Ada sees name, message,
filenames, and sizes.
No login. No owner email.
No storage keys in the HTML.

On download, recheck the
transfer: active, not
expired, not revoked. Only
then mint a signed URL for
that one R2 object, valid
for a few minutes. The next
file repeats the check.

Evaluate expiry at read
time. If now is past the
expiry, refuse even if a
cleanup job has not run.
Expired, revoked, and
unknown slugs render the
same generic sentence.

Objects stay private.
Never put an r2.dev URL
in the page or in mail.

Do not add a passcode gate.
Do not add download-all.
Do not add a preview.

Done when Ada downloads
brand-guide.pdf, a signed
URL pasted after the TTL
fails, revoke blocks the
next GET, and a clock
pushed past 7 Nov 2026
shows the dead page
without deleting the row.

What to check after Ada saves the PDF

Open /t/rome-js-launch as Ada. Click the PDF. The address bar still says /t/rome-js-launch. View source. No r2.dev, no storage key.

Copy the signed URL from the network tab. Wait out the TTL. Paste it in another window. It fails.

Revoke while the tab is open. Click the PNG. Dead page, or a refused grant. Do not show “revoked by Flavio”.

Set expiry to a time that already passed, or move the clock. Refresh. Dead page. The files can still sit in R2. Cleanup is the 10-hour row.

If the agent served the object from a public bucket, send prompt 4 back.

Failures the agent ships anyway

A public object URL on the page

a href="https://pub-….r2.dev/…/brand-guide.pdf" is a permanent copy. Store private. Sign per file. The hosting post says the same thing: put the file through the Worker. Do not paste an r2.dev link into the email.

A live slug with a missing file

If publish ignores the complete flag, Ada gets a 404 on the second button and thinks you forgot the logo. You notice when the dashboard says 2 files and one GET returns nothing. Incomplete uploads never activate.

Expiry that waits for a cron

A job that flips status at midnight is late, or early, depending on the timezone. You notice when Ada downloads at 00:02 on 8 Nov. Check now against the timestamp on every grant.

A dead page that talks too much

“Rome JS launch kit is no longer available” tells a leaked slug that the package existed. Missing, expired, revoked, and junk slugs use one sentence.

Folders and sync on the 3-hour row

The third estimate is a month and up for file sync, shared folders, and previews. If folders or a desktop watcher appears in the plan, you left Transfer and started Dropbox.

A passcode sitting in the URL

Shared links get forwarded. A ?code= query is not a secret. Passcodes are the 10-hour row, hashed, and they travel separately.

Deploy the transfer page

Parcel wants Astro, R2, and Workers. D1 holds the rows. Put it on a subdomain you already own, drop.flaviocopes.com. Mail is the only background job on the 3-hour row.

The hosting post is that path: the adapter, then the bindings, then the custom domain.

Attach the domain on the Worker. Open /t/rome-js-launch on your phone on cellular. Download the PDF. Revoke on the laptop. Refresh the phone. Confirm the dead page.

Take a D1 backup and an R2 listing before you delete the paid account. The active packages are the files you will miss.

Use it as the real send for a month. If you miss a passcode, a download count, or automatic delete, those are the 10-hour row. Then cancel Dropbox Transfer, or WeTransfer, whichever one you pay for.

When you add cleanup later, evaluate expiry at read time first. The job only deletes objects. A cron line is enough to start that job.

[triggers]
crons = ["0 4 * * *"]

What to skip on purpose

Leave out file synchronization, shared folders, previews for every format, and permanent storage.

3-HOUR V1 Upload until every file is done Publish an expiring slug Signed download, then revoke SKIP THIS HALF File sync Shared folders Previews for every format
Ship the 3-hour package. Leave sync, folders, and previews on the skip row.

Passcodes, branding, download tracking, and automatic cleanup wait until one real client has already saved the files. The second mini-prompt on the card lives there. Hash the passcode. Rate-limit guesses. Keep it out of the URL.

A streamed ZIP of everything is a later nicety. Individual downloads are the v1. Images you resize later belong on Cloudinary, $89–$249 a month.

The first-project post still points at a tonight card. This is the afternoon after that, when you still rent a page to send two files.

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 Dropbox Transfer. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: upload files, expiring download link. Three hours.

Send https://drop.flaviocopes.com/t/rome-js-launch to one client who is waiting on a zip.