Stream private video from S3 or R2 with HTTP Range requests
A Next.js video player for private S3 or R2 objects that seeks with 206 range responses, using signed URLs or a session-checked proxy route.
A <video> element seeks by asking for byte ranges: it sends Range: bytes=…, and a server that supports ranges answers 206 Partial Content with only that slice. S3 and R2 both honor Range on GetObject, signed URLs included, so the cheapest private player gives the element a signed URL and lets storage serve every range. When you need the session checked on every request, the Files SDK gateway can proxy the ranges instead (downloadMode: "proxy") and answer the 206s itself.
The catch is the signed URL’s lifetime. Storage checks the signature on each request, and the browser reuses the same URL for every seek. A URL that expires mid-video breaks the next seek, so it has to outlive the viewing session, and a long-lived URL works for anyone who copies it.
Before you start
- A private S3 or R2 bucket with your videos (this guide uses an S3 bucket called
videosand keys likelessons/intro.mp4), and server-side credentials for it. - A Next.js App Router app with an auth library that resolves the signed-in user on the server.
- Written against files-sdk 3.0, Next.js 16.4, and React 19.3. Browser behavior described below was observed in Chrome 154 against a local MinIO server; check it in the other browsers you support.
npm install files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignerpnpm add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigneryarn add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignerbun add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignernub add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigneraube add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignerOn R2, use r2({ bucket: "videos" }) from files-sdk/r2 instead. Both adapters sign GET URLs and serve ranges, and everything below applies to either.
How a video seeks
You don’t write any range code for the signed-URL paths. It helps to know what goes over the wire when something breaks:
- The element opens the file with
Range: bytes=0-. A206response withContent-Range: bytes 0-30084138/30084139tells it the server can serve slices and how big the file is. - On a seek to a part it hasn’t buffered, it sends a new request such as
Range: bytes=17334272-. - If the server ignores
Rangeand answers200with the whole file, the video still plays from the start. In Chrome, the element then reports nothing as seekable, and dragging the playhead snaps back.
MDN’s range request guide covers Accept-Ranges, 206, 416, and If-Range in general. S3 serves one range per request, and every media request in the local Chrome test asked for exactly one.
Pick a delivery path
| Signed URL in the page | Gateway redirect | Gateway proxy | |
|---|---|---|---|
<video src> |
A signed storage URL | /api/videos?op=download&key=… |
/api/videos?op=download&key=… |
| Who answers the range requests | S3 or R2 | S3 or R2, after one 302 |
Your server |
| When access is checked | When the page renders | Each time the element loads src |
On every range request |
| URL expiry matters | Yes | Yes, but a reload of src recovers |
No |
| Video bytes through your server | None | None | All of them |
| Works without signed URLs | No | No | Yes, but seeking needs range support |
Start with the gateway redirect for an app player: the page holds a URL that’s useless without a session, and storage still does the byte serving. Use the proxy when access must be re-checked on every request, or when the adapter can’t sign URLs.
Set up the storage client
import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";
export const files = new Files({
// Credentials come from the AWS credential chain.
adapter: s3({ bucket: "videos", region: "us-east-1" }),
});
Upload videos with their real type, for example files.upload("lessons/intro.mp4", file, { contentType: "video/mp4" }). Storage sends back the stored Content-Type on every range response.
The examples use two placeholders from @/lib/auth. getSession(headers) stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check) and returns { user: { id: string } } or null. canWatch(userId, key) stands in for your entitlement check, such as a purchases or enrollments table, and returns a boolean.
Option 1: put a signed URL in the page
A server component checks the session, signs a URL, and renders it:
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { canWatch, getSession } from "@/lib/auth";
import { files } from "@/lib/files";
export default async function LessonPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!/^[\w-]+$/.test(id)) {
notFound();
}
const session = await getSession(await headers());
if (!session) {
redirect("/sign-in");
}
const key = `lessons/${id}.mp4`;
if (!(await canWatch(session.user.id, key))) {
notFound();
}
// Long enough to cover the viewing session, pauses and seeks included.
const src = await files.url(key, { expiresIn: 4 * 60 * 60 });
return <video controls playsInline preload="metadata" src={src} />;
}
The page is dynamic because it reads request headers, so every visit signs a fresh URL. The trade-off is that the URL itself is the credential. “Copy video address” hands someone a link that plays for up to four hours without a session.
Option 2: redirect through the gateway
Mount a download-only gateway route. operations refuses every other verb before authorize runs, and authorize checks the session and the entitlement:
import { FilesError } from "files-sdk";
import { createFilesRouter } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";
import { canWatch, getSession } from "@/lib/auth";
import { files } from "@/lib/files";
const router = createFilesRouter({
files,
operations: ["download"],
// How long each redirect's signed URL lives.
defaultExpiresIn: 4 * 60 * 60,
authorize: async ({ req, key }) => {
const session = await getSession(req.headers);
if (!session) {
throw new FilesError("Unauthorized", "Sign in to watch");
}
if (!key || !(await canWatch(session.user.id, `lessons/${key}`))) {
// 404 rather than 403, so the route doesn't confirm the video exists.
throw new FilesError("NotFound", "Video not found");
}
return { keyPrefix: "lessons/" };
},
});
export const { GET } = createRouteHandler(router);
The player points at the route and recovers from an expired URL by loading src again, which makes the gateway sign a fresh one:
"use client";
import { useRef } from "react";
export function VideoPlayer({ src }: { src: string }) {
const ref = useRef<HTMLVideoElement>(null);
const retried = useRef(false);
function recover() {
const video = ref.current;
// An expired signed URL surfaces as a network error. Retry once.
if (
!video ||
retried.current ||
video.error?.code !== MediaError.MEDIA_ERR_NETWORK
) {
return;
}
retried.current = true;
const resumeAt = video.currentTime;
video.addEventListener(
"loadedmetadata",
() => {
video.currentTime = resumeAt;
},
{ once: true }
);
video.src = src;
}
return (
<video
controls
onError={recover}
onPlaying={() => {
retried.current = false;
}}
playsInline
preload="metadata"
ref={ref}
src={src}
/>
);
}
The lesson page renders the player with the route’s URL. The route does the access check, so the page signs nothing:
import { VideoPlayer } from "./video-player";
export default async function LessonPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const key = encodeURIComponent(`${id}.mp4`);
return <VideoPlayer src={`/api/videos?op=download&key=${key}`} />;
}
The element’s first request goes to your route with the user’s session cookie, since it’s same-origin. authorize runs, and the gateway answers 302 with Cache-Control: private, no-store and a signed URL that expires in defaultExpiresIn seconds. The client can’t ask for a longer one on this path.
In Chrome 154 against local MinIO, the gateway saw exactly one request per load: the opening Range: bytes=0-. Every later range request, including seeks, went straight to the signed storage URL. With a deliberately short 20-second lifetime, a seek after expiry stalled while Chrome retried the expired URL for about 30 seconds, then fired error with MEDIA_ERR_NETWORK. The recovery above then reloaded through the gateway (a second 302 in the route’s log) and resumed at the same position.
The signed URL asks storage to send Content-Disposition: attachment, the gateway’s default for downloads. Chrome played the video normally with it; the header matters only when someone opens the URL directly, where it forces a download.
Option 3: proxy every range through the gateway
Add downloadMode: "proxy" to the createFilesRouter options in app/api/videos/route.ts, and keep the same player. defaultExpiresIn no longer matters, because nothing is signed.
Now every range request reaches your route. authorize runs each time, the gateway heads the object, parses Range, and streams the slice from storage with a 206. Nothing expires mid-video, and revoking access takes effect on the next request.
You pay for that in requests and bandwidth. Each browser request costs an authorize call, a HEAD, and a ranged GET against storage, and the bytes flow through your server. In the local Chrome test, a single seek produced six range requests to the route. On serverless hosts, check how your plan bills function duration and bandwidth for long streamed responses before you send video through it.
Inspect the responses with curl
Point curl at the route with a session cookie and a Range header. With the proxy-mode gateway in front of a local MinIO bucket, serving a 30 MB test file stored as video/mp4, the responses looked like this (trimmed to the relevant headers):
curl -s -o /dev/null -D - -b "session=…" -H "Range: bytes=0-1023" \
"http://localhost:3000/api/videos?op=download&key=intro.mp4"
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: video/mp4
X-Content-Type-Options: nosniff
ETag: "5bfbd550cb99f1d231a2c739aff908fa"
Last-Modified: Fri, 09 Oct 2026 05:26:26 GMT
Content-Disposition: attachment
Content-Range: bytes 0-1023/30084139
Content-Length: 1024
A range that starts past the end gets a 416 with the real size:
curl -s -o /dev/null -D - -b "session=…" -H "Range: bytes=40000000-" \
"http://localhost:3000/api/videos?op=download&key=intro.mp4"
HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */30084139
Content-Length: 0
If-Range guards against splicing two versions of a file. Send the ETag from an earlier response: while it still matches, you get the 206. If the object changed, you get 200 OK and the whole new file. The S3 adapters report ETags without quotes, so the proxy adds them to its ETag header, and it accepts the validator back quoted or bare. An HTTP date equal to Last-Modified (to the second) also matches. A weak validator (W/"…") never does.
curl -s -o /dev/null -D - -b "session=…" -H "Range: bytes=0-1023" \
-H 'If-Range: "5bfbd550cb99f1d231a2c739aff908fa"' \
"http://localhost:3000/api/videos?op=download&key=intro.mp4"
The proxy answers a malformed or multi-range header (bytes=0-1,5-6) with a plain 200 and the whole file. A suffix range (bytes=-1000) gets the last 1,000 bytes.
On the redirect path, the same command shows the 302 and its Location, because curl only follows redirects when you pass -L. Request the Location URL with a Range header to see storage answer the 206 itself.
How long the signed URL must live
- Cover the session, not the video. Viewers pause, scrub back, and leave tabs open. Pick a lifetime from how long people watch, then decide how long a leaked link may keep working. With the gateway redirect, the player’s reload recovers from expiry, so you can choose a shorter lifetime than the signed-URL-in-page option needs.
- Storage checks the expiry per request. AWS documents that a download already in progress continues past the expiry, but a restarted one fails. Every seek is a restarted request.
- The ceiling is seven days for SigV4 URLs, on S3 and R2. The S3 and R2 adapters throw for a longer
expiresInrather than return a URL storage would refuse. - Temporary credentials cut it shorter. A URL signed with role or STS credentials stops working when those credentials expire, whatever
expiresInsays. AWS’s FAQ notes that ECS task credentials typically rotate every 1 to 6 hours and anAssumeRolesession lasts 1 hour by default. If your server signs with a role, the role session is your real maximum. - Cap it per user if you need to.
authorizecan returnmaxExpiresIn, which clamps the redirect’s lifetime belowdefaultExpiresIn.
CORS
A plain <video src> makes no CORS request. MDN’s crossorigin reference says that without the attribute the browser doesn’t use CORS, so a video from your bucket’s domain plays without any bucket CORS rule. Add crossorigin only when you need it, for example to draw frames onto a <canvas> without tainting it. Then the bucket needs a CORS rule that allows GET from your origin. The proxy path is same-origin and needs no rule at all.
Where range requests don’t work
Range support depends on the adapter. These adapters report files.capabilities.rangeRead as false, and download({ range }) throws on them: Appwrite, Bunny Storage, Convex, Netlify Blobs, Supabase, and Vercel Blob in private mode. Every S3-compatible adapter, R2 included, supports ranges.
Through the gateway’s proxy path, a non-range adapter can’t answer with a slice, and onUnsupportedRange decides what it does instead. In front of an in-memory store with range support turned off, the gateway answered:
onUnsupportedRange |
Range: bytes=0- |
Any other range, such as bytes=1000- |
|---|---|---|
"reject" (default) |
200 with the whole file |
416 |
"ignore" |
200 with the whole file |
200 with the whole file |
bytes=0- asks for everything from the first byte, which the whole file satisfies, so both settings answer the element’s opening request the same way: a 200 with Accept-Ranges: none. As in How a video seeks, the video then plays from the start but can’t seek. That’s acceptable for short clips. For anything people seek through, serve video from a range-capable store. For private Vercel Blob files, Share private Vercel Blob files covers what the adapter offers instead.
This guide covers progressive playback of a single file. Adaptive streaming (HLS or DASH, with multiple renditions and a manifest) is a separate kind of product, with its own packaging and players.
Troubleshooting
The video plays, but the seek bar doesn’t work. Something answered 200 instead of 206. Run the curl check against each hop: your route, the signed URL, and any CDN or proxy in front of them. If your route’s response carries Accept-Ranges: none, the adapter behind the proxy can’t serve ranges.
The video never loads, and the route logs a 416 for bytes=0-. The app runs files-sdk 2.6 or earlier, where the default onUnsupportedRange: "reject" refused that opening range on a non-range adapter. Upgrade, or set onUnsupportedRange: "ignore".
Seeking stalls after a while, then MEDIA_ERR_NETWORK. The signed URL expired, or the credentials that signed it did. Reload through the gateway as in the player above, raise the lifetime, or check whether your server signs with short-lived role credentials.
A 403 from storage on the signed URL. The signature is stale or invalid. A local MinIO server answered an expired URL with AccessDenied and Request has expired. Sign a new URL rather than reusing one from a cached page.
401 from /api/videos. getSession returned null for the media request. The request is same-origin, so the browser sends cookies with it; check that the session cookie’s Path covers /api/videos.