Skip to main content
Rails never sees the video file. It creates the video in Bunny Stream and signs an upload, and the browser sends the file to us over TUS. With import maps, none of it needs a JavaScript build.

Rails example on GitHub

A Rails 8 app using import maps.

Quickstart

1

Add import maps

A default rails new app already uses import maps. An app made with rails new --minimal has no JavaScript set up, so add them.
2

Add your library credentials

.env
Copy both from your library’s API page. Rails won’t read .env on its own, so load it with dotenv-rails in development.
3

Create the Bunny Stream module

sign_upload hashes the library ID, API key, expiry, and video ID with SHA-256. The key never leaves this module.
app/models/bunny_stream.rb
One rescue_from turns its errors into JSON for every controller.
app/controllers/application_controller.rb
4

Add the routes

Put the upload routes behind your own authentication before you deploy. The create route makes a video in your library and hands back a signature that lets the caller upload into it. Left open, anyone who finds the URL can fill your library with uploads that you pay to store, encode, and deliver.Server Actions and API routes are public HTTP endpoints, even when nothing in your UI links to them. Check the user on every request, and check that they own a video ID before you re-sign it or return its status.
config/routes.rb
resumable? checks that we’re still waiting on the file before re-signing an existing video.
app/controllers/api/uploads_controller.rb
app/controllers/api/videos_controller.rb
5

Upload from the browser

The page needs a file input and somewhere to show the result. The default layout already renders csrf_meta_tags and javascript_importmap_tags.
app/controllers/home_controller.rb
app/views/home/show.html.erb
Pin the upload code and tus-js-client, and import the upload code. Keep any pins and imports you already have. jsDelivr’s +esm build bundles tus-js-client into one file, which an import map can pin.
config/importmap.rb
app/javascript/application.js
The X-CSRF-Token header carries the token from csrf_meta_tags. Rails rejects the POST without it.
app/javascript/components/video_uploader.js
tus-js-client sends the credentials as headers with every request. The video ID goes into localStorage against the file, which is how a reload finds its way back to the same upload. Add this to the same file.
abort() pauses. start() picks up from the last chunk we acknowledged.A 401 from the TUS endpoint means the signature doesn’t match the headers. Check that the library ID and API key belong to the same library. A 400 means the expiry has already passed. Re-signing keeps the upload’s original expiry, as the TUS FAQ explains.

Play it once it’s encoded

We start encoding when the last chunk arrives. Poll your status route until status reaches 4 (finished), 5 or 6 (failed), then embed embedUrl. This goes in the same file too.
encodeProgress gives you a percentage to show in the meantime. A webhook tells your server when encoding finishes. Then wire both to the page’s #video-file input and #video-output element. Picking another file cancels the current upload.
Start the server with bin/rails server, open http://localhost:3000, and choose a video.

Before you deploy

Add a before_action that requires a signed-in user and records who owns each video ID. In production, set the two variables on the host.

Troubleshooting

The layout needs <%= csrf_meta_tags %>, and the fetch needs the X-CSRF-Token header.
Last modified on October 6, 2026