Add video upload and playback to Remix

Use FastPix SDKs to add resumable uploads and adaptive-bitrate playback to a React Router (Remix) application.

Uploading video is different from uploading other files. Large uploads need to resume after interruptions, and media must be processed into streaming formats before they can be played reliably across devices.

FastPix handles uploads, processing, and adaptive streaming. Your application only needs to create a signed upload URL on the server and render a player. Uploads go directly from the browser to FastPix, so video data never passes through your server.

This guide uses React Router in Framework Mode because it provides server rendering for creating upload URLs and receiving webhooks while keeping your FastPix credentials on the server. If you’d rather not use React components, the quickstart covers the same workflow using the API directly, and resumable uploads for web covers the underlying @fastpix/resumable-uploads engine.

NOTE:
Remix and React Router. Remix v2 has been upstreamed into React Router and is in maintenance mode, so create-remix now directs new projects to create-react-router. On an existing Remix v2 codebase the code is the same: import from @remix-run/node and @remix-run/react instead of react-router, and register routes with file conventions rather than routes.ts.


How it works

The upload workflow is straightforward:

  1. Your React Router server creates a signed upload URL using the FastPix Node SDK.
  2. The React Uploader uploads the selected file directly to FastPix using the signed URL.
  3. FastPix processes the uploaded media asynchronously.
  4. FastPix sends a video.media.ready webhook when the media is ready for playback.
  5. Your application retrieves the playback ID and plays the video using the FastPix Player.

What you’ll build

By the end of this guide, you’ll have:

  • A React Router application that uploads videos directly to FastPix.
  • Resumable uploads with upload progress and retry support.
  • A FastPix Player that streams the uploaded video.
  • A workflow that waits for media processing before playback.

Before you begin


Install

From your project directory:

$cd your-react-router-project
$npm install @fastpix/fastpix-node @fastpix/fp-react-uploader @fastpix/fp-player

Each package covers one part of the flow: the Node SDK talks to the FastPix API from your server, the React Uploader is the browser-side upload UI, and the Web Player plays the result.

Add your credentials to .env. React Router reads them into process.env on the server:

$FASTPIX_USERNAME=your-access-token-id
$FASTPIX_PASSWORD=your-secret-key

WARNING:
Do not prefix these with VITE_. React Router builds with Vite, which inlines every VITE_* variable into the client bundle. That would publish your Secret Key to every visitor.

Create the SDK client in a .server.ts file. The suffix tells React Router this module is server-only, so it’s never bundled for the browser:

1// app/fastpix.server.ts
2import { Fastpix } from "@fastpix/fastpix-node";
3
4export const fastpix = new Fastpix({
5 security: {
6 username: process.env.FASTPIX_USERNAME,
7 password: process.env.FASTPIX_PASSWORD,
8 },
9});

Import the uploader stylesheet in the route that renders it:

1import "@fastpix/fp-react-uploader/styles.css";

Without it the components render as an unstyled skeleton and you supply the entire look yourself. With it you get the default drop zone, progress track, status text, and buttons, which you can then adjust through the appearance prop or the --fastpix-* CSS variables, both documented in the uploader README.


Create an upload URL

Signed upload URLs are created with your Secret Key, so this belongs on the server. A resource route (a route module with no default export) gives you an endpoint the browser can call:

app/routes/api.upload-url.ts

1import { fastpix } from "../fastpix.server";
2
3export async function action() {
4 const upload = await fastpix.inputVideo.upload(
5 { corsOrigin: "*", pushMediaSettings: { accessPolicy: "public" } },
6 { headers: { "X-Client-Type": "web-browser" } },
7 );
8
9 if (!("data" in upload)) throw new Error("Could not create an upload.");
10
11 return Response.json({ url: upload.data!.url, mediaId: upload.data!.uploadId });
12}

app/routes/api.upload-url.js

1import { fastpix } from "../fastpix.server";
2
3export async function action() {
4 const upload = await fastpix.inputVideo.upload(
5 { corsOrigin: "*", pushMediaSettings: { accessPolicy: "public" } },
6 { headers: { "X-Client-Type": "web-browser" } },
7 );
8
9 return Response.json({ url: upload.data.url, mediaId: upload.data.uploadId });
10}

Register it in app/routes.ts, along with the routes added later in this guide:

1import { type RouteConfig, index, route } from "@react-router/dev/routes";
2
3export default [
4 index("routes/home.tsx"),
5 route("upload", "routes/upload.tsx"),
6 route("play/:mediaId", "routes/play.tsx"),
7 route("api/upload-url", "routes/api.upload-url.ts"),
8 route("api/fastpix/webhook", "routes/api.fastpix.webhook.ts"),
9] satisfies RouteConfig;

NOTE:
X-Client-Type: web-browser tells FastPix that a browser will perform the upload, so the signed URL is issued for browser use. Send it whenever the file is uploaded from a browser, including this setup, where your server requests the URL and the browser uploads to it.

Leave it out when your own server, a CLI, or a native Android or iOS app uploads the bytes. See upload videos from device.

Keep the mediaId. It identifies the media everywhere else in the API, and you’ll use it later to play the video. Full options are in upload videos from device, or use create media from a URL to import video you already host.


Add the uploader

Give <FastPixUploader> a function instead of a URL. It runs when a file is picked, so a URL is minted per upload rather than per page load:

app/routes/upload.tsx

1import { FastPixUploader } from "@fastpix/fp-react-uploader";
2import "@fastpix/fp-react-uploader/styles.css";
3
4async function createUpload() {
5 const response = await fetch("/api/upload-url", { method: "POST" });
6 const { url } = await response.json();
7 return url as string;
8}
9
10export default function Upload() {
11 return <FastPixUploader endpoint={createUpload} accept="video/*" />;
12}

app/routes/upload.jsx

1import { FastPixUploader } from "@fastpix/fp-react-uploader";
2import "@fastpix/fp-react-uploader/styles.css";
3
4async function createUpload() {
5 const response = await fetch("/api/upload-url", { method: "POST" });
6 const { url } = await response.json();
7 return url;
8}
9
10export default function Upload() {
11 return <FastPixUploader endpoint={createUpload} accept="video/*" />;
12}

That’s a working uploader: drag and drop, a file picker, progress, and pause, resume, and cancel controls.

You can select or drag a video into the uploader and the upload begins with visible progress.


Build your own layout instead

Pass the sub-components as children and arrange them yourself. Each reads upload state from the parent through context, so placement and order are yours:

1import {
2 FastPixUploader,
3 FastPixDropZone,
4 FastPixStatus,
5 FastPixTrack,
6 FastPixStartButton,
7 FastPixPauseButton,
8 FastPixResumeButton,
9 FastPixAbortButton,
10} from "@fastpix/fp-react-uploader";
11
12<FastPixUploader endpoint={createUpload} autoStart={false} size="lg" appearance={{ accentColor: "#00d1ff" }}>
13 <FastPixDropZone overlay>
14 <p>Drag a video here, or click to browse</p>
15 </FastPixDropZone>
16
17 <FastPixStatus />
18 <FastPixTrack showLabel />
19
20 <FastPixStartButton />
21 <FastPixPauseButton />
22 <FastPixResumeButton />
23 <FastPixAbortButton />
24</FastPixUploader>

autoStart={false} holds the file until someone presses start, which is why a start button appears here and not in the default layout.

Tracking progress yourself, driving the uploader from a ref, reading live state with useUploaderContext(), and building a fully headless UI are covered in the uploader README.


Respond to upload events

The uploader reports its lifecycle through callback props: onProgress, onSuccess, onError, and more. All are optional:

1<FastPixUploader
2 endpoint={createUpload}
3 accept="video/*"
4 onProgress={(percent) => console.log(percent)}
5 onSuccess={() => {
6 /* the bytes are delivered; encoding continues */
7 }}
8 onError={(error) => console.error(error.message)}
9/>

onSuccess fires when the bytes finish uploading. The media still has to be encoded before it can play, which is what the next section covers. The full callback list is in the uploader README.


Wait for the media to be ready

A finished upload isn’t a playable video yet. It still has to be encoded. FastPix sends a video.media.ready webhook once playback is available.

Register your endpoint under Org Settings > Webhooks (see Set up webhooks), then handle the event in another resource route:

app/routes/api.fastpix.webhook.ts

1import type { Route } from "./+types/api.fastpix.webhook";
2
3export async function action({ request }: Route.ActionArgs) {
4 const { type, data } = await request.json();
5
6 if (type === "video.media.ready") {
7 /* data.id is the mediaId, data.playbackIds[0].id is the playback ID.
8 Store them against your own record here. */
9 await markVideoReady(data.id, data.playbackIds[0].id);
10 }
11
12 return Response.json({ message: "ok" });
13}

app/routes/api.fastpix.webhook.js

1export async function action({ request }) {
2 const { type, data } = await request.json();
3
4 if (type === "video.media.ready") {
5 await markVideoReady(data.id, data.playbackIds[0].id);
6 }
7
8 return Response.json({ message: "ok" });
9}

WARNING:
Verify the signature before trusting a payload. Your endpoint is a public URL, so anyone who finds it could claim a video is ready. Check the FastPix-Signature header, an HMAC-SHA256 of the raw body, as described in Set up webhooks. Read the body with request.text() and verify that exact string. A parsed and re-serialized body produces different bytes and never matches.

Other events, including video.media.failed, are in the webhook event reference.

Webhooks can’t reach localhost, so while developing either expose your dev server through a tunnel or check the status directly:

1const media = await fastpix.manageVideos.get({ mediaId });
2// media.data.status === "Ready"

Play the video

<fastpix-player> is a web component, so it registers itself against customElements, a browser-only API. React Router server-renders your routes, so import the player after mount rather than at module scope. The element upgrades itself as soon as the definition arrives:

app/components/Player.tsx

1import { useEffect } from "react";
2
3export default function Player({ playbackId }: { playbackId: string }) {
4 useEffect(() => {
5 import("@fastpix/fp-player");
6 }, []);
7
8 return <fastpix-player playback-id={playbackId} stream-type="on-demand" />;
9}

app/components/Player.jsx

1import { useEffect } from "react";
2
3export default function Player({ playbackId }) {
4 useEffect(() => {
5 import("@fastpix/fp-player");
6 }, []);
7
8 return <fastpix-player playback-id={playbackId} stream-type="on-demand" />;
9}

Give it a size, since it fills whatever container it sits in:

1fastpix-player { width: 100%; aspect-ratio: 16 / 9; }

Fetch the media in a loader. That runs on the server, so your credentials stay there and only the playback ID reaches the browser:

app/routes/play.tsx

1import { fastpix } from "../fastpix.server";
2import Player from "../components/Player";
3import type { Route } from "./+types/play";
4
5export async function loader({ params }: Route.LoaderArgs) {
6 /* Read the playback ID from your own database, or from FastPix like below. */
7 const media = await fastpix.manageVideos.get({ mediaId: params.mediaId! });
8
9 if (!("data" in media)) throw new Response("Media not found.", { status: 404 });
10
11 return { playbackId: media.data!.playbackIds![0].id! };
12}
13
14export default function Play({ loaderData }: Route.ComponentProps) {
15 return <Player playbackId={loaderData.playbackId} />;
16}

app/routes/play.jsx

1import { fastpix } from "../fastpix.server";
2import Player from "../components/Player";
3
4export async function loader({ params }) {
5 const media = await fastpix.manageVideos.get({ mediaId: params.mediaId });
6
7 return { playbackId: media.data.playbackIds[0].id };
8}
9
10export default function Play({ loaderData }) {
11 return <Player playbackId={loaderData.playbackId} />;
12}

A playback ID isn’t the same as a media ID. One media asset can carry several playback IDs with different access policies. This guide uses accessPolicy: "public", so the ID alone is enough to play the video. Private and DRM playback need a signed token, covered in play uploaded videos along with autoplay, captions, and the player’s full attribute and event surface.


Troubleshooting

Uploads fail with “error code 0” after 5 attempts, but the video still processes.

The signed URL was created without the X-Client-Type: web-browser header, so it isn’t valid for uploads started from a browser. Add it to your upload call as shown above. The rule is about who uploads the bytes, not who requests the URL. A browser doing the upload needs the header even though your server is the one asking for the URL.

A new route 404s.

Framework mode uses explicit registration, not file-name discovery. Add the route to app/routes.ts.

Property 'mediaId' does not exist on params, or missing ./+types/....

Route types are generated. Run react-router typegen (npm run typecheck does this first), and restart the dev server after editing routes.ts.

ReferenceError: window is not defined, or the build fails on the player.

The player is being imported at module scope, so it’s evaluated during server rendering. Load it inside useEffect, as above.

Cannot find module '@fastpix/fp-react-uploader/styles.css' (TS2882).

TypeScript has no declarations for CSS imports. The React Router template already sets "types": ["node", "vite/client"] in tsconfig.json; if that was changed, restore vite/client.

The Secret Key ends up in the client bundle.

Import the SDK only from a .server.ts module, and never from a file a route component imports directly.

Chunk size is rejected.

chunkSize is in KB, between 5120 and 512000, in multiples of 256.


What’s next