How to build a Vimeo alternative
How to build a Vimeo alternative uploads one original file, plays it from an unlisted link, and counts each view without storing who watched.
How to build a Vimeo alternative is one file, a page that plays it, and a count of those plays. Vimeo is that product. The catalog line is “Your videos on your own domain, minus the platform chrome.”
The card lists $20–$65 a month. That is $240–$780 a year. The verdict is “The player is the easy part”. The first estimate is 10 hours. The project name on the card is Reelhouse.
The Vimeo post walks through Falkor, CMAF, and a player that switches quality. Those pieces are the other product. This guide is the file you already exported.
A tab recording is the Loom guide. Loom is $15–$24 a person a month. Notes pinned to a frame are Frame.io, $15–$25 a person a month. A file that expires is the file transfer guide. Dropbox Transfer is $20–$32 a person a month. The sample in this guide is pin-demo.mp4, the 48-second clip from the Vimeo post. The title on the page is Pin demo.
How to build a Vimeo alternative from one file
The Vimeo page splits the work into three rows.
The first row is 10 hours. Uploads, a branded player, unlisted links, and view counts. You upload a file. You get a page. The original file plays.
The second row is 3 days, 24 hours. Captions, chapters, folders, password links, and analytics charts. A transcript can wait until Pin demo already plays.
The third row is 3 months and up, 480 hours. An adaptive transcoding pipeline, DRM, live streaming, and apps.
If you tell an agent to “clone Vimeo”, it aims at the third row. Paste the prompts below and keep Pin demo on the first row.
What the 10-hour build does
You drop in pin-demo.mp4. The server checks the type and the size before any byte is stored. The upload is split into parts. If the network dies, a retry continues from the last finished part. The library shows the video only after every part is verified and assembled. Until then the row is pending, and a visitor cannot hit it.
You scrub to a frame, capture it in the browser, and that JPEG becomes the poster. You copy an unlisted link. A viewer opens it, the player loads the original file, and after a few seconds of playback the day’s count goes up by one. Refreshing the same tab does not add another. You can mint a second link, and revoking the first one leaves the second one playing. The embed for a link dies with that link.
A replace upload keeps the old file on the existing links until the new file is fully assembled. Then the links play the new file. The slugs stay.
What it leaves out
No captions, chapters, or folders. No password on top of the unguessable slug. No chart of the daily numbers. Those sit on the 24-hour row. No renditions, no live room, no DRM, no app. The same v1 also replaces Wistia if what you wanted was a file on your domain. The card names Wistia and does not give it a price.
Where the file lives
One owner. Node.js and Astro render the library and the player page. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The video row, the links, and the daily counters live in D1. The original file and the poster live in R2, under keys a stranger cannot guess, and the bucket is not listable. The card already names R2 for the file.
The browser asks the server for an upload grant. The server checks the declared type and size, writes a pending video row, and returns the part instructions. Parts go to object storage. The library query only returns rows whose state is ready. A pending row can show in an “uploading” strip for the owner, with progress, and it has no player URL.
When every part is present, the server marks the row ready and stores the byte size it actually wrote. If the browser reports a duration, store that too. The 48-second sample should come back as 48 seconds. If the browser reports nothing, leave duration empty and still allow the video. You can fill it later from a player that managed to read the file.
Playback never uses the storage key as the public URL. A live share link mints a signed URL that expires after 15 minutes and allows range requests, so seeking works on the original file. The player page is server-rendered with a fresh signed URL. With the script off, the video element still has a source. A long watch that outlives the 15 minutes asks for a new URL and seeks back to the current time. That refresh is the same viewing, so it does not increment the count.
The view write is a separate small request. It carries the video id and the viewer’s local date. The server accepts the date only when it is a real calendar day within one day of the server clock, then increments the counter for that video and that date. The table has those two columns and a number. There is no visitor id, no IP, and no user agent stored. The “once per session” part is a sessionStorage flag on the viewer’s browser, keyed by video and date. Clearing the session can count again. The upload prompt on the product page calls that an acceptable tradeoff, because you want a trend.
Revocation is a column on the link. The next request for a revoked or unknown slug renders one gone page. That page has no title and no poster. Sibling links of the same video still mint signed URLs. An embed is a player route bound to one link, so it follows the same rule.
Replace is a second upload attached to the same video. While it is pending, signed URLs still point at the old key. On success, the row points at the new key and a cleanup deletes the old object. On abandonment, the row keeps the old key.
If R2 errors mid-part, the pending row stays pending and the finished parts stay. A retry uses the same upload id. If D1 errors before the pending row exists, the grant fails and storage has nothing to clean. A daily job deletes pending rows older than a day, and their parts. Ready videos are left alone.
The codec check is the browser’s. If the video element errors, the page says this browser cannot play the file. The count request is not sent. You replace the file with one the browser can play. Playback stays on the file you uploaded.
What a video actually stores
The card lists Video, ShareLink, and ViewEvent. All three exist in these 10 hours. ViewEvent is the daily counter, one row per video per date, with a number. It is not a row per play and not a row per person.
A video is Pin demo. It holds the title, the description, the state, the object key, the poster key, the byte size, the duration when you have it, and timestamps. State moves from pending to ready. A replace sets state to replacing, then back to ready, and only then swaps the key. Delete removes the row, its links, its counters, and the objects. There is no trash.
The type check allows video/mp4 and video/webm. Anything else is rejected at the grant, including a renamed text file. The size cap for this v1 is 2 GB. That is the file the upload prompt on the product page uses for a transfer that dies halfway. Over the cap, the grant is refused and no row is written.
A share link belongs to one video. The slug is random and long enough that guessing it is hopeless. Many links can be live. Each has created time and, once revoked, a revoked time. Revoke is immediate. Reissue means a new slug. The old slug stays on the gone page.
A view counter is (video id, local date) and an integer. The player sends the count after 5 seconds of playback. I use 5 seconds so a bounce does not count. The product prompt says “a few seconds”. Five is the number this build uses. Five refreshes in one session stay at one, which is the check the prompt already names. A bot or a link-preview user agent is ignored, so a chat app unfurling the link does not count. The server still increments atomically when two real sessions arrive together.
The poster is a JPEG the browser captured. Capture waits for the seeked event, and the button stays disabled until a frame has been drawn, so you do not save a black rectangle or the previous frame. The canvas keeps the video’s aspect ratio. A fallback strip of frames sampled through the file lets you pick without scrubbing. The poster is stored before the player page will offer it. Until you pick one, the player shows the first frame the browser can decode on its own.
The screens you ship
The library, the upload, the player, and the video detail. Captions and the chart wait.
Library
Each ready row shows the poster, the title, the duration, the file size, and the view total. Actions are open, copy link, and copy embed. An uploading strip lists pending files with progress and a resume control. An empty account says “No videos yet” and has one upload button. A pending file is absent from any player URL.
Upload
The title starts as Pin demo. You can edit it. The description is optional. The drop zone names the file. Progress is per part, and a retry button appears if a part fails. The poster step is on this screen after the file is whole: a muted player, a capture button, and the sampled strip. Save on the poster stores the JPEG. The video can already be ready before you pick a poster.
Player
A header with the site name, the video, the title, and the description. Pin demo has no related videos and no comments. The corner does not show a view count to the guest. If the element errors, the sentence “This browser cannot play this file” replaces the spinner. A revoked slug shows “This link is gone” and nothing else.
Video detail
The owner sees each link with its created time and a revoke button, the daily counts as a list of dates and numbers, the embed snippet for the selected link, and replace file. Revoke asks you to confirm that link’s slug ending, so a mis-click does not kill every link. Replace shows the same part progress as upload, and a line that says the current file keeps playing until the new one is ready.
Screens that wait
Captions are a file you attach and a track on the video element. Chapters are timestamps on a rail. The podcast host guide uses that shape for audio. On a video they wait until the 24-hour row. Folders group ready videos. A password is a second gate on one link. The chart is the daily integers you already store, drawn later. None of these change the object key.
From a dropped upload to one view
You choose pin-demo.mp4. The grant accepts it. Three parts finish, then the network drops. The strip says resume. You retry. The finished parts stay finished. The row flips to ready and appears in the library. You capture a poster. You create a link and open it in a private window. The file plays. You refresh four more times. The detail page shows 1 for today’s local date.
You create a second link and revoke the first. The first window, on reload, says the link is gone and does not name Pin demo. The second link still plays. You start a replace and close the laptop before it finishes. The second link still plays the original 48-second file the next morning. The daily job then drops the abandoned replace parts.
You rename a text file to .mp4 and try to upload it. The grant refuses. The library is unchanged. You try a file over 2 GB. Same refusal, and no pending row.
Prompt 1. A hidden row until the file is whole
Copy the Astro prompt from Build your own Vimeo. Paste this above it.
Ship the 10-hour row only. Project name: Reelhouse.
Node.js and Astro. Video rows, share links, and daily
counters live in D1. Files and posters live in R2.
TanStack Start is the same screens and the same rules,
with server functions, a SQL database, and object storage.
One owner. A video stores title, description, state,
object key, poster key, byte size, duration if known.
State is pending, ready, or replacing.
The library lists ready videos only.
Pending videos show in an owner strip and have no player URL.
Empty library copy: No videos yet.
Delete removes the row, its links, its counters, and the objects.
Do not transcode. Do not add captions, chapters, folders,
passwords, or charts.
No public bucket listing.
When the plan lists routes and tables, stop.
What to check after the library
Create nothing by hand in the database and confirm the empty state. A logged-out window does not see the library. After a later prompt has uploaded Pin demo, a pending row during that upload returns no player page. Delete on a ready video removes the row and the object. The table list is videos, share_links, and view_counters.
Prompt 2. Parts that resume, then a poster
Add the upload and the poster on Reelhouse.
The browser asks for a grant. The server allows video/mp4
and video/webm only, and refuses anything over 2 GB,
before any byte is stored. A refused grant writes no row.
Sniff the container. A renamed text file is refused.
Split the file into parts the storage API accepts.
The last part may be shorter. Persist the upload id
and the completed part numbers.
A retry resumes from the last finished part.
When every part is verified, set state to ready
and store the byte size. Store duration if the browser
reported one. The 48-second sample stores 48.
A daily job deletes pending and replacing rows older
than a day, and their leftover parts. It does not touch
ready videos.
Poster: muted player, capture the current frame to a JPEG
after the seeked event, keep the aspect ratio.
Disable capture until a frame has been drawn.
Offer a strip of sampled frames.
Store the JPEG in R2 and show it on the player.
Replace file uses the same part flow.
Until the new object is verified, signed playback
keeps the old key. Then swap the key and delete the old object.
Abandoning a replace leaves the old key.
Done when a killed upload resumes, an abandoned upload
never appears as playable, and a captured poster matches
the paused frame after reload.
What to check after the upload
Upload pin-demo.mp4. Kill the network after the first part and retry. The bar does not start at zero. The library shows Pin demo only at the end, with the byte size. Duration reads 48 seconds if the browser reported it. Turn on airplane mode and leave a second upload pending overnight, or set the job’s cutoff to a minute in a dev build. The pending row and its parts are gone. Upload a .txt renamed to .mp4. No new row. Start a replace, abandon it, and confirm the player still serves the first file.
Prompt 3. Links, a signed URL, and a gone page
Add share links and playback on Reelhouse.
A video may have many links. The slug is unguessable.
The player route and the embed route resolve a slug
that is still live. Revoked, unknown, and never-issued
slugs all render one page: This link is gone.
That page includes no title and no poster.
Revoking one link leaves the other links of that video playing.
The player src is a signed URL minted for that request.
It expires after 15 minutes and allows range requests,
so seeking works on the original file.
If playback outlives the URL, fetch a new one and seek
to the current time. That refresh does not count a view.
With script off, the video element still has a source
from the server render.
The embed snippet is the player route for one link.
Copy it from the detail page.
The detail page lists links, created time, revoke,
daily counts as a date list, and replace file.
Confirm revoke by showing the end of that slug.
If the video element errors, show:
This browser cannot play this file.
Do not offer the storage key as a download.
Done when revoking link A leaves link B playing,
and a copied media URL stops working after 15 minutes.
What to check after the links
Open the link in a private window. Pin demo plays, and you can seek. View source and confirm the storage key is absent. Revoke that link and reload. The gone page does not say Pin demo. A second link still plays. Copy the embed onto a local HTML file and confirm it uses the same slug. Wait out the signed URL, or mint one with a one-minute expiry in dev, and confirm the old media URL fails while a reload of the page plays.
Prompt 4. One count per session per local date
Add view counts on Reelhouse.
A counter row is video id, local date, and an integer.
No visitor id, no IP, no user agent column.
The player sends the date from the viewer's calendar,
only after currentTime has passed 5 seconds of playback.
Reject a date that is not a real YYYY-MM-DD
or is more than one day from the server clock.
Increment atomically.
sessionStorage remembers video id plus date for this session.
Five refreshes in that session send one increment.
Ignore user agents you treat as link-preview crawlers
and bots. A codec error does not send the count.
A signed-URL refresh does not send the count.
The guest player does not display the number.
The owner detail page lists the dates and the integers.
Done when five refreshes stay at 1, the schema has
no person column, and two browsers on the same video
both land in the counter.
What to check after the count
Play past 5 seconds. The detail list shows 1 for today. Refresh four more times. Still 1. Open a second browser and play past 5 seconds. The number is 2. Seek around before 5 seconds and leave. The number stays 2. Confirm the table columns with a query. You should see video id, date, and count.
Failures the agent ships anyway
A public object URL
The player src is the raw R2 key, or the bucket allows listing. Anyone who can guess or list keys can watch every video, including pending ones. Point the player at a signed URL from a live slug, and keep the bucket private. Check the network panel. The host should be the signed one, and a directory listing of the bucket should fail.
A zombie in the library
The row is created as ready when the grant is issued. A failed upload still has a title, and the player spins forever. Create the row as pending, and let the library query filter on ready. The resume check is the one that catches this. Kill the network and look at the library before you retry.
Last part wins, old file gone
Replace deletes the old object when the new upload starts. A failed replace leaves every link dead. Delete the old object only after the new key is stored on the row.
A visitor table
The counter grows a user id, a hashed IP, or a fingerprint “just in case”. The schema check is a query against the table definitions. Three columns on the counter. The session flag lives in the browser and is allowed to reset.
The gone page names the video
Revoke works, and the page still says Pin demo. Then a leaked slug tells you the title forever. Use one template for revoked and unknown slugs, with fixed copy.
A transcode button
The upload grows a “optimize” control that calls ffmpeg. The 480-hour row is the one that makes renditions. This row stores the file you uploaded. Remove the control.
The 24-hour row, after Pin demo plays
Play the 48-second file from a link you can revoke. Then add the pieces the card names next.
Captions are a WebVTT file on the video, shown as a track, removable without touching the object key. Chapters are a list of seconds and titles on the owner detail, rendered under the player. Folders are a name and a list of ready videos. A password sits on one link, checked before the signed URL is minted. A wrong password shows the form again. The gone page stays for a revoked slug. The chart reads the counter rows you already have. It does not collect a new event.
The scope post is the three-row habit. The prompt post is the line that keeps a transcode out of the first prompt.
Deploy, then play the 48 seconds
Reelhouse wants Astro, D1, and R2. Put it on a hostname you already own, videos.flaviocopes.com. The hosting post is the adapter and the domain. Take a backup of D1 before you delete a paid account. The backup is the rows. The objects in R2 are the files themselves, so deleting that bucket deletes Pin demo.
Log a grant with the video id, the declared size, and accepted or the refusal reason. Log assembly with the video id and the byte size. Log link created and link revoked with the video id and the link id, and leave the slug out of the log. Log a view with the video id and the date. Leave titles out of the log. A demo can be private. When the daily job deletes a pending row, log the video id and the age.
Then use it. Upload a real file, capture a poster, send one link, revoke it, and read the count.
What to skip on purpose
Leave adaptive transcoding, DRM, live streaming, and native apps. Leave a visitor table. Leave a public bucket. The Vimeo post is the picture of that later pipeline, including why a 20-minute file is a different problem from this 48-second one.
If the rent is “watch this click path”, build the Loom guide. If the rent is “notes at 00:42”, build Frame.io when you get to that card. If the rent is “the file I exported, on my domain”, build this.
Start now
Go to Build your own Vimeo. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate. Uploads, a branded player, unlisted links, view counts. Ten hours. The player plays the original file.
Upload pin-demo.mp4. Open the link. Reload, and read 1.