Direct upload has exactly one step that can't happen in a browser. Creating the signed upload URL requires your Secret Key, and a Secret Key in client code is a published Secret Key. Everything after that point, including the file bytes themselves, goes straight from the browser to storage without touching your infrastructure. So the only architectural question a framework changes is where that single server-side call lives, and what it can read from the environment.
TL;DR
Every framework needs exactly one server-side call to create a signed upload URL, and the call itself is identical everywhere. Next.js puts it in a route handler. React Router uses a resource route, SvelteKit a +server.ts file, Astro an API route with prerender = false, Laravel a controller. Vite and React Native have no server, so you deploy one. Send X-Client-Type: web-browser when a browser pushes the bytes. Omit it when a native app does. Getting that backwards fails in opposite directions. Every one of them will publish your Secret Key if you prefix it, and three refuse to build instead.
Where the signed upload URL is created
The operation doesn't vary by framework. You ask FastPix for an upload URL. Back comes a URL and an upload ID. Hand only the URL to the client. The client uploads to it. Both halves are documented in upload a video from a device.
What varies is the file the call sits in and how that file reads credentials. Below is every framework in the FastPix docs, with the exact location and the exact environment mechanism.
| Framework | Where the URL is created | File | Reads credentials via |
|---|---|---|---|
| Next.js | Route handler | app/api/upload-url/route.ts | process.env, never NEXT_PUBLIC_ |
| React Router v7 | Resource route action | app/routes/api.upload-url.ts | process.env inside .server.ts, never VITE_ |
| SvelteKit | +server.ts POST handler | src/routes/api/upload-url/+server.ts | $env/static/private |
| Astro | API route with prerender = false | src/pages/api/upload-url.ts | import.meta.env, never PUBLIC_ |
| Laravel | Controller action | app/Http/Controllers/VideoController.php | config('fastpix.username') |
| React (Vite) | A server you deploy separately | server.js or a serverless function | server environment, never VITE_ |
| React Native (Expo) | The backend the app already talks to | server.js | server environment |
Vite and Expo have no server of their own, so the call has nowhere native to go and you deploy one. The other five ship a server, so the call goes in a file the framework already understands.
What you need before you start
- An app in any of the seven frameworks above.
- A FastPix account with an Access Token ID and Secret Key from activate your account.
- Somewhere to run server code, which the first five frameworks already give you.
Five frameworks ship a server for the upload URL, two do not
This split decides how much work the upload feature is. Next.js, React Router, SvelteKit, Astro and Laravel all run server code as part of the framework. Adding an upload endpoint is adding one file.
Vite is a build tool. vite build emits static HTML, JavaScript and CSS with no runtime behind them. An upload endpoint therefore has to be deployed elsewhere. A small Express service, a serverless function beside the static build, or a route on a backend you already run. The full architecture is in where your API lives in a Vite React app.
React Native is the same shape for a different reason. The app runs on a device, and anyone can extract strings from a mobile binary. The Secret Key can't ship in the bundle at all. The app calls a backend you control and receives a URL.
Astro sits between the two groups and catches people out. It generates a static site by default. An API route silently fails to exist until you install an adapter and set export const prerender = false on it.
The video upload API call, in the three shapes it takes
Across seven frameworks there are only three code shapes. Four of them run the Node SDK identically, differing only in the file's export signature.
Node, in a framework route. Next.js, React Router, SvelteKit and Astro all make this call. Only the wrapper changes:
import { Fastpix } from "@fastpix/fastpix-node";
const fastpix = new Fastpix({
security: {
username: process.env.FASTPIX_USERNAME,
password: process.env.FASTPIX_PASSWORD,
},
});
const upload = await fastpix.inputVideo.upload(
{ corsOrigin: "*", pushMediaSettings: { accessPolicy: "public" } },
{ headers: { "X-Client-Type": "web-browser" } },
);
// upload.data.url goes to the client. upload.data.uploadId stays with you.That's the whole server-side surface, and it's the same four lines everywhere the Node SDK runs. You can run it against a real upload on the free plan. That covers 10 videos and 100K streaming minutes a month, no credit card.
PHP, in a Laravel controller. Same operation, different SDK surface, and the header is set once on the HTTP client rather than per call:
$upload = FastPixService::make()->inputVideo->directUploadVideoMedia(
new DirectUploadVideoMediaRequest(
corsOrigin: '*',
pushMediaSettings: new PushMediaSettings(
accessPolicy: DirectUploadVideoMediaAccessPolicy::Public,
),
),
);Node, for a native device upload. The React Native case is the same SDK with one deliberate omission, covered in the next section.
The header is about who pushes the bytes
X-Client-Type: web-browser is the single most misread part of this flow, and it has a specific failure mode in both directions.
The header describes who uploads the file, not who asks for the URL. Your server is always the one asking. If a browser is going to push the bytes, the header goes on, even though the server made the request.
Leaving it off produces a failure that reads as unrelated. The upload fails with error code 0 after five attempts, and the media still processes. Something clearly worked, so the header looks innocent.
Send it when a native app is going to push the bytes and you get the opposite problem. The browser uploader performs a resumable-session handshake, while the React Native uploader PUTs chunks directly. A device handed the browser-flavoured URL is using a session it never opened, and the reported symptom is an HTTP 400 on each chunk. Only the browser direction is in the docs. The other is a support observation rather than documented behaviour, so treat it as one.
| Who pushes the bytes | Header | Failure if you get it wrong |
|---|---|---|
| Browser | X-Client-Type: web-browser | Error code 0 after 5 attempts, media still processes |
| Native iOS or Android app | Omit it | Reported as HTTP 400 on every chunk, undocumented |
| Your own server or a CLI | Omit it | Same handshake mismatch as the row above |
Every framework here publishes your Secret Key if you prefix it
Every build tool here has a prefix that marks a variable as public, and the prefix isn't a suggestion. The value is inlined into the bundle at build time, so any code path that reads it puts the literal string in a file served to every visitor.
| Framework | The prefix that publishes | What protects you |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | Nothing automatic. Naming discipline only. |
| Vite and React Router | VITE_ | Nothing automatic. Naming discipline only. |
| Astro | PUBLIC_ | Nothing automatic. Naming discipline only. |
| SvelteKit | PUBLIC_ | $env/static/private refuses to build if a client module imports it |
| Laravel with Vite | VITE_ | config:cache breaks env() outside config files |
Three of them turn this into a build error rather than a silent leak. SvelteKit's $env/static/private, React Router's .server modules and Astro's astro:env/server all refuse to build when client code imports them. Next.js and plain Vite rely on you never prefixing a Secret Key to make it visible. People do prefix it. Prefixing is exactly what makes the error message go away.
Laravel's version of this isn't a leak but it looks like one. Credentials read through env() outside a config file stop resolving the moment you run php artisan config:cache. The app works locally and breaks on deploy. Read them through config('fastpix.username') instead.
Keep the upload ID, not just the upload URL
The response carries two values and most first implementations keep only one. The URL is what the client needs. The upload ID is what you need, because it identifies the media everywhere else in the API.
Store it against your own record before the upload starts, alongside whatever media metadata you set on the upload. When encoding finishes, the video.media.ready webhook arrives carrying data.id and data.playbackIds[0].id. Without your own row there's nothing to attach the playback ID to. The verification step that webhook needs is covered in the video.media.ready webhook, and how to verify it.
Frequently Asked Questions (FAQs)
Why cannot I create a signed upload URL in the browser?
Because creating one requires the Secret Key. Any value a browser can read is a value every visitor can read. Build tools make it worse by inlining prefixed environment variables at build time, so the key ships as a literal string in a JavaScript file. The upload URL itself is safe to hand to the client; the credential that mints it is not.
Do I need a backend to upload video?
You need somewhere to run one function. Frameworks like Next.js, SvelteKit, Astro, React Router and Laravel already run server code, so it is one extra file. A Vite app or a React Native app has no server. Deploy a small endpoint, or add a route to a backend you already run. The file bytes still bypass it entirely.
Does the video file pass through my server?
No. Your server only creates the signed URL. The client uploads directly to that URL, so bandwidth, memory and request-size limits on your own infrastructure never come into play. This is why a 4 GB upload works on a serverless function with a 4.5 MB request cap.
When do I send the X-Client-Type header?
Send X-Client-Type: web-browser whenever a browser will push the bytes, including when your server is the one requesting the URL. Omit it when a native mobile app, a CLI, or your own server pushes the bytes. The rule tracks the uploader, not the requester. Get it backwards and you see the documented error code 0 in one direction, and a reported HTTP 400 on every chunk in the other.
Can the same backend serve web and mobile uploads?
Yes, with one branch. Create the upload with the browser header for web clients and without it for native clients. A flag from the caller is usually enough. The rest of the call, including the access policy and the upload ID you store, is identical.





