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.

  • guide
  • vibe-coding
  • vimeo

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.

10 HOURS · SHIP Original file, resumable Player and unlisted links A daily view count 24 HOURS · NEXT Captions and chapters Folders and a password Charts of the daily counts 480 HOURS · SKIP Adaptive transcoding DRM and live streaming Native apps
Hours are the three estimates on the Vimeo card. The 10-hour box is the build in this guide.

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.

OWNER BROWSER Parts, poster canvas, library ASTRO ON THE REQUEST Grant checks type and size Ready rows mint a signed URL Revoked slug gets one gone page D1 Video, links Count per day R2 Original file Poster JPEG VIEWER BROWSER Plays, then one count per session OUTSIDE THIS VERSION A public bucket listing Captions, chapters, folders Transcodes and a live room A visitor table
The row becomes visible when the parts are whole. The player receives a short-lived URL, and the counter stores a date.

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.

VIDEO · PIN DEMO pending, then ready object key, poster, bytes 48 seconds when known SHARE LINK slug, created revoked time many per video VIEW COUNTER video id + date one integer no person column REPLACE Old key serves until the new file is assembled. Slugs stay. LATER A caption track A visitor id on the counter
Three tables. The counter's whole identity is which video and which local date.
WHAT THE COUNTER DOES 5 refreshes, one session +1 Under 5 seconds +0 Session cleared, play again +1 Link-preview crawler +0 Revoked link no play
The widths follow the rule, so the zero cases are easy to see beside the counted plays.

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.

WIREFRAME · LIBRARY Pin demo · ready 48s · poster · view total Copy link · Copy embed Uploading · resume Hidden from every player URL UPLOAD
Ready rows play. The yellow strip is a file that has not assembled yet.

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.

WIREFRAME · UPLOAD Title · Pin demo pin-demo.mp4 · parts Poster · paused frame CAPTURE Wait for seeked A .txt renamed to .mp4 is refused Over 2 GB never opens a row
Progress is the original file. Capture runs in the browser after the seek finishes.

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.

WIREFRAME · PLAYER poster, then the file Pin demo Description under the title Guest does not see the count
The page is the player. The count happens in the background after 5 seconds.

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.

CHOOSE PIN-DEMO.MP4 TYPE AND SIZE OK? NO No row is written YES Pending, then parts RESUME AFTER A DROP READY · LINK PLAYS REVOKED Gone page, no title LIVE Play, maybe +1 REPLACE NOT FINISHED Old file keeps playing DAY-OLD PENDING IS DELETED
A bad grant writes nothing. A live link can count. An unfinished replace leaves the old object in place.

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.

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.

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.

Related articles

Read all articles →