The shape of adding video to a Next.js app: upload or import the file to a dedicated video provider from a Route Handler or server action, store the returned video id (not the file) in your own database, and render an adaptive HLS/DASH player inside a "use client" component. Your Next.js server should never touch the video bytes — it issues the upload, gets an id back, and points the browser straight at the provider’s CDN.
Why Next.js shouldn’t serve the video itself
It’s tempting to drop an MP4 in public/ or stream it through a Route Handler — fine for a 5-second demo clip, wrong the moment you have real users.
Route Handlers and server actions run as functions — on Vercel or any serverless host they have execution time limits, memory limits, and a cold start on every invocation. A GET handler piping a multi-gigabyte file through Response.body holds that function open for the whole playback session, burns compute budget, and adds a hop (client → your server → origin) a CDN edge would have skipped entirely. If you’re paying per-invocation or per-GB egress on your own infra, that’s a fast way to blow up a bill for no benefit — a CDN is already closer to the viewer than your app server will ever be.
The bigger problem is that <video src="file.mp4"> isn’t how video gets delivered at scale. Without adaptive bitrate streaming, every viewer gets the same file regardless of connection — the person on a train and the person on gigabit fiber both wait on the same download. Adaptive streaming (HLS, DASH) needs multiple encoded renditions plus a manifest, produced by an encoding pipeline your Next.js app has no business running. That’s what a video API is for: rehelios encodes for free and serves the renditions over per-GB storage and delivery, so your app’s job shrinks to “get an id, render a player.”
Uploading: a Route Handler that hands off, not one that receives
The upload flow belongs behind a Route Handler or server action, but its job is to broker the upload, not perform it. Two shapes cover almost everything:
Direct upload. The client asks your server action for an upload target; your server action calls the rehelios API (using a server-side API key) to create a video and get back a resumable upload URL; the browser then uploads directly to that URL over TUS, in chunks, resuming automatically if the connection drops. The file bytes go browser → rehelios, never through your Next.js server.
// app/actions/videos.ts
"use server";
export async function createUploadTarget() {
const res = await fetch("https://api.rehelios.com/v1/videos", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.REHELIOS_API_KEY}` },
});
const { id, uploadUrl } = await res.json();
await db.insert(videos).values({ id, status: "uploading" });
return { id, uploadUrl };
}
Import by URL. If the video already lives somewhere (an S3 bucket, a partner feed), skip the upload and give rehelios the URL — it fetches and encodes it server-side. Same shape, just a sourceUrl field instead of a TUS session.
Either way, don’t poll for encoding status. Register a webhook endpoint (another Route Handler) that rehelios calls, HMAC-signed, when the video flips to ready. Verify the signature, update the row in your database, and your UI picks it up on the next render — no cron job checking “is it done yet” every few seconds. (More on that in webhook vs polling.)
Rendering: adaptive playback in a client component
Playback has to happen in a "use client" component — <video> needs a ref, event listeners, and in most browsers a JavaScript HLS implementation, none of which a server component can do. Safari (desktop and iOS) plays HLS natively through the <video> tag; everywhere else you need hls.js to parse the manifest and feed segments into a MediaSource. The standard pattern checks canPlayType first and only loads hls.js if native support isn’t there:
"use client";
import { useEffect, useRef } from "react";
export function VideoPlayer({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
return;
}
let hls: import("hls.js").default | undefined;
import("hls.js").then(({ default: Hls }) => {
if (!Hls.isSupported()) return;
hls = new Hls();
hls.loadSource(src);
hls.attachMedia(video);
});
return () => hls?.destroy();
}, [src]);
return <video ref={videoRef} controls playsInline className="w-full" />;
}
The dynamic import("hls.js") keeps the library out of the bundle for Safari users who don’t need it. src here is the HLS (or DASH, with a DASH-capable player) manifest URL rehelios returns once the video is ready — a server component fetching the video’s status can pass that straight down as a prop. If iOS is giving you trouble specifically, the iOS Safari HLS checklist covers the usual suspects.
Private video: mint the token on the server, not the client
Public playback URLs are fine for a marketing site, wrong for anything gated — course content, internal recordings, paid video. rehelios supports signed playback tokens plus a domain allowlist: a token is generated server-side with a secret that never reaches the browser, carries an expiry, and is checked against the referring domain before any segment is served.
In practice, the player URL is built inside a server component or server action, right where you already check the viewer’s session — getPlaybackUrl(videoId, { userId }) returns a signed, time-limited URL, and only that URL gets passed to the client VideoPlayer. The client never sees the signing key, and a copied URL stops working once it expires or leaves the allowlisted domain.
Further reading: the docs cover the upload, webhook, and playback-token APIs in full; if you’re deciding between streaming formats, see HLS vs DASH; and for the mechanics behind the token pattern above, see how signed playback tokens work.