> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://fastpix.com/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://fastpix.com/docs/_mcp/server.

# Add video upload and playback to Remix

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](/docs/get-started/quickstart) covers the same workflow using the API directly, and [resumable uploads for web](/docs/upload-videos/set-up-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

* A FastPix account with an **Access Token ID** and **Secret Key**. See [get your Access Token ID and Secret Key](/docs/getting-started/activate-your-account#get-your-access-token-id-and-secret-key).
* A React Router application running in Framework Mode.
* React 18 or later.
* Node.js.

\


## Install

From your project directory:

```bash
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](https://github.com/FastPix/node-sdk) talks to the FastPix API from your server, the [React Uploader](https://github.com/FastPix/react-web-uploader) is the browser-side upload UI, and the [Web Player](/docs/web-player/install-the-fastpix-web-player) plays the result.

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

```bash
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:

```ts
// app/fastpix.server.ts
import { Fastpix } from "@fastpix/fastpix-node";

export const fastpix = new Fastpix({
  security: {
    username: process.env.FASTPIX_USERNAME,
    password: process.env.FASTPIX_PASSWORD,
  },
});
```

Import the uploader stylesheet in the route that renders it:

```tsx
import "@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](https://github.com/FastPix/react-web-uploader#appearance).

\


## 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`**

```ts
import { fastpix } from "../fastpix.server";

export async function action() {
  const upload = await fastpix.inputVideo.upload(
    { corsOrigin: "*", pushMediaSettings: { accessPolicy: "public" } },
    { headers: { "X-Client-Type": "web-browser" } },
  );

  if (!("data" in upload)) throw new Error("Could not create an upload.");

  return Response.json({ url: upload.data!.url, mediaId: upload.data!.uploadId });
}
```

**`app/routes/api.upload-url.js`**

```js
import { fastpix } from "../fastpix.server";

export async function action() {
  const upload = await fastpix.inputVideo.upload(
    { corsOrigin: "*", pushMediaSettings: { accessPolicy: "public" } },
    { headers: { "X-Client-Type": "web-browser" } },
  );

  return Response.json({ url: upload.data.url, mediaId: upload.data.uploadId });
}
```

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

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("upload", "routes/upload.tsx"),
  route("play/:mediaId", "routes/play.tsx"),
  route("api/upload-url", "routes/api.upload-url.ts"),
  route("api/fastpix/webhook", "routes/api.fastpix.webhook.ts"),
] 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](/docs/upload-videos/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](/docs/upload-videos/upload-videos-from-device), or use [create media from a URL](/docs/video-on-demand-api/upload-and-import-videos/create-media) 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`**

```tsx
import { FastPixUploader } from "@fastpix/fp-react-uploader";
import "@fastpix/fp-react-uploader/styles.css";

async function createUpload() {
  const response = await fetch("/api/upload-url", { method: "POST" });
  const { url } = await response.json();
  return url as string;
}

export default function Upload() {
  return <FastPixUploader endpoint={createUpload} accept="video/*" />;
}
```

**`app/routes/upload.jsx`**

```jsx
import { FastPixUploader } from "@fastpix/fp-react-uploader";
import "@fastpix/fp-react-uploader/styles.css";

async function createUpload() {
  const response = await fetch("/api/upload-url", { method: "POST" });
  const { url } = await response.json();
  return url;
}

export default function Upload() {
  return <FastPixUploader endpoint={createUpload} accept="video/*" />;
}
```

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:

```tsx
import {
  FastPixUploader,
  FastPixDropZone,
  FastPixStatus,
  FastPixTrack,
  FastPixStartButton,
  FastPixPauseButton,
  FastPixResumeButton,
  FastPixAbortButton,
} from "@fastpix/fp-react-uploader";

<FastPixUploader endpoint={createUpload} autoStart={false} size="lg" appearance={{ accentColor: "#00d1ff" }}>
  <FastPixDropZone overlay>
    <p>Drag a video here, or click to browse</p>
  </FastPixDropZone>

  <FastPixStatus />
  <FastPixTrack showLabel />

  <FastPixStartButton />
  <FastPixPauseButton />
  <FastPixResumeButton />
  <FastPixAbortButton />
</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](https://github.com/FastPix/react-web-uploader).

\


## Respond to upload events

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

```tsx
<FastPixUploader
  endpoint={createUpload}
  accept="video/*"
  onProgress={(percent) => console.log(percent)}
  onSuccess={() => {
    /* the bytes are delivered; encoding continues */
  }}
  onError={(error) => console.error(error.message)}
/>
```

`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](https://github.com/FastPix/react-web-uploader#events).

\


## 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](/docs/webhooks/set-up-webhooks)), then handle the event in another resource route:

**`app/routes/api.fastpix.webhook.ts`**

```ts
import type { Route } from "./+types/api.fastpix.webhook";

export async function action({ request }: Route.ActionArgs) {
  const { type, data } = await request.json();

  if (type === "video.media.ready") {
    /* data.id is the mediaId, data.playbackIds[0].id is the playback ID.
       Store them against your own record here. */
    await markVideoReady(data.id, data.playbackIds[0].id);
  }

  return Response.json({ message: "ok" });
}
```

**`app/routes/api.fastpix.webhook.js`**

```js
export async function action({ request }) {
  const { type, data } = await request.json();

  if (type === "video.media.ready") {
    await markVideoReady(data.id, data.playbackIds[0].id);
  }

  return Response.json({ message: "ok" });
}
```

> **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](/docs/webhooks/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](/docs/webhooks/webhook-event-reference).

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

```ts
const media = await fastpix.manageVideos.get({ mediaId });
// 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`**

```tsx
import { useEffect } from "react";

export default function Player({ playbackId }: { playbackId: string }) {
  useEffect(() => {
    import("@fastpix/fp-player");
  }, []);

  return <fastpix-player playback-id={playbackId} stream-type="on-demand" />;
}
```

**`app/components/Player.jsx`**

```jsx
import { useEffect } from "react";

export default function Player({ playbackId }) {
  useEffect(() => {
    import("@fastpix/fp-player");
  }, []);

  return <fastpix-player playback-id={playbackId} stream-type="on-demand" />;
}
```

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

```css
fastpix-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`**

```tsx
import { fastpix } from "../fastpix.server";
import Player from "../components/Player";
import type { Route } from "./+types/play";

export async function loader({ params }: Route.LoaderArgs) {
  /* Read the playback ID from your own database, or from FastPix like below. */
  const media = await fastpix.manageVideos.get({ mediaId: params.mediaId! });

  if (!("data" in media)) throw new Response("Media not found.", { status: 404 });

  return { playbackId: media.data!.playbackIds![0].id! };
}

export default function Play({ loaderData }: Route.ComponentProps) {
  return <Player playbackId={loaderData.playbackId} />;
}
```

**`app/routes/play.jsx`**

```jsx
import { fastpix } from "../fastpix.server";
import Player from "../components/Player";

export async function loader({ params }) {
  const media = await fastpix.manageVideos.get({ mediaId: params.mediaId });

  return { playbackId: media.data.playbackIds[0].id };
}

export default function Play({ loaderData }) {
  return <Player playbackId={loaderData.playbackId} />;
}
```

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](/docs/web-player/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

* [React Uploader](https://github.com/FastPix/react-web-uploader), [Web Player](https://github.com/FastPix/web-player-component), [Node SDK](https://github.com/FastPix/node-sdk), and [Web Uploads SDK](https://github.com/FastPix/web-uploads-sdk).
* [Upload videos from device](/docs/upload-videos/upload-videos-from-device), [Resumable uploads for web](/docs/upload-videos/set-up-resumable-uploads-for-web), and [Get media](/docs/video-on-demand-api/manage-videos/get-media).
* [Install the web player](/docs/web-player/install-the-fastpix-web-player), [Play uploaded videos](/docs/web-player/play-uploaded-videos), and [Webhooks](/docs/webhooks/set-up-webhooks).