React Router in Framework Mode has exactly the right shape for video upload. A resource route gives you the one server-side step, a loader gives you the playback ID, and the .server.ts convention keeps credentials off the client. Three things then go wrong on the first attempt, and none of them is about video. The route 404s, because Framework Mode registers routes in a config file by default rather than reading them off filenames. The types are missing, because they're generated. And the Secret Key ends up in the browser bundle, because React Router builds with Vite.
TL;DR
A resource route is a route module with no default export, and that's where the signed upload URL gets minted in an action. Creating the file isn't enough. Framework Mode registers routes in app/routes.ts by default. Add it there and the dev server picks it up; without that entry it 404s with the file sitting right there. Route types come from react-router typegen rather than inference, so run npm run typecheck before debugging a missing params.mediaId. Keep credentials unprefixed, since VITE_ inlines them into the client bundle, and import the SDK only from a .server.ts module.
Only one step of React Router video upload needs the server
The file doesn't pass through your application. Your server mints a signed upload URL, the browser uploads directly to that URL, and your route returns a few hundred bytes of JSON.
That's what makes upload-size limits, request timeouts and memory pressure irrelevant here. A 3 GB upload and a 3 MB upload put identical load on your server, because neither one touches it.
| What runs where | React Router | Browser |
|---|---|---|
| Create signed upload URL | Resource route action | No |
| Transfer the file | No | Yes, directly to storage |
| Receive the ready webhook | Resource route action | No |
| Fetch the playback ID | loader | No |
| Render the player | No | Yes |
What you need before you start
- A React Router application in Framework Mode, React 18 or later.
- A FastPix account with an Access Token ID and Secret Key from activate your account.
- Credentials in
.envwith noVITE_prefix, for the reason in the last section.
The React Router resource route that creates the upload URL
No default export means nothing renders, so the module is an endpoint:
// app/routes/api.upload-url.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 });
}action handles POST, loader handles GET. An upload URL is a creation, so it's an action. Its full option surface is in upload a video from a device.
You can prove the route against a real upload first. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.
Keep the mediaId and store it against your own record before the upload starts, because the readiness webhook matches on it later.
Why a React Router resource route 404s
This is the first failure and it produces a 404 on a file that visibly exists.
Framework Mode reads its route list from app/routes.ts by default. Creating app/routes/api.upload-url.ts puts a file on disk and nothing else. The URL exists once routes.ts says so:
// app/routes.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;The dotted filename is a convention for readability, not a routing mechanism. The URL comes from the first argument to route(), and the two don't have to match.
Editing routes.ts is picked up without a restart. The dev server reloads the route config and regenerates the route types, so the new URL answers on the next request.
Filename routing is available if you prefer it. @react-router/fs-routes exports flatRoutes(), which reads the app/routes directory the way Remix v2 did, and it's the usual choice for teams arriving from Remix.
The uploader takes a function, not a URL
endpoint takes a function, so a URL is minted when a file is picked rather than on page load:
// app/routes/upload.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/*" />;
}That stylesheet import isn't optional in the way it looks. Without it the components render as an unstyled skeleton and the entire look becomes yours to supply. With it you get the drop zone, progress track, status text and buttons, adjustable through the appearance prop or the --fastpix-* CSS variables.
A TypeScript project may reject the CSS import with TS2307, Cannot find module. That's TypeScript having no declaration for the .css file, and the React Router template already sets "types": ["node", "vite/client"] in tsconfig.json, which supplies one. If someone removed vite/client, restore it.
React Router route types are generated, so run typegen
The second failure looks like a TypeScript bug. params.mediaId doesn't exist, or ./+types/play can't be found.
Those types are generated from routes.ts rather than inferred. react-router typegen produces them, and npm run typecheck runs that first. The fix is usually to run the command rather than to change any code:
// app/routes/play.tsx
import { fastpix } from "../fastpix.server";
import Player from "../components/Player";
import type { Route } from "./+types/play";
export async function loader({ params }: Route.LoaderArgs) {
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} />;
}The dev server reloads routes.ts on save here too, since the generated types follow the config.
Why VITE_ publishes your Secret Key in React Router
React Router builds with Vite, and Vite inlines every VITE_-prefixed environment variable into the client bundle at build time.
That makes prefixing a Secret Key an act of publication rather than a configuration shortcut. Any code path that reads it puts the literal string in a JavaScript file served to every visitor.
The .server.ts suffix is the protection. It tells React Router the module is server-only, never to be bundled for the browser:
// 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 that module only from loaders, actions and other server files. If a route component reaches it, the build fails with Server-only module referenced by client rather than shipping the key, so the suffix is enforced rather than advisory.
The player has its own rule, and it's about weight rather than safety. Import <fastpix-player> inside useEffect rather than at module scope, so its bundle stays out of the initial payload. Releases before 1.0.21 also crashed server rendering; current ones guard the customElements registration.
Register one route and start uploading
Create app/routes/api.upload-url.ts with an action and no default export, add it to routes.ts, and check the route is in routes.ts before you conclude anything is broken. Then point <FastPixUploader> at it with a function rather than a URL string. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. Push a file large enough to have broken a form-based route.
Frequently Asked Questions (FAQs)
How do I handle large video uploads in React Router v7?
Create a signed upload URL in a resource route action and return it to the client. The browser then uploads directly to that URL. The file never passes through your server, so request size limits and timeouts do not apply. Register the route in app/routes.ts because config-based registration is the Framework Mode default.
Why does my React Router resource route return 404?
Because config-based registration is the Framework Mode default rather than filename discovery. Creating the file is not enough; add it to app/routes.ts with route("api/upload-url", "routes/api.upload-url.ts"). No restart is needed: the dev server reloads the config and regenerates the route types. Filename routing is still available if you want it through the @react-router/fs-routes package.
What is a resource route in React Router?
A route module with no default export. Nothing renders, so the module acts as an endpoint: action handles POST and loader handles GET. It is where server-only work such as minting a signed upload URL or receiving a webhook belongs.
Why does params.mediaId not exist on my route types?
Because route types are generated from routes.ts rather than inferred. Run react-router typegen, or npm run typecheck, which runs it first. The dev server picks up routes.ts without a restart and regenerates the types with it.
How do I keep FastPix credentials out of the React Router client bundle?
Put them in .env with no VITE_ prefix. React Router builds with Vite, and Vite inlines every prefixed variable into the browser bundle at build time. Create the SDK client in a .server.ts module and import it only from loaders, actions and other server files.






