Add video upload and playback to Astro

Use FastPix SDKs to add resumable uploads and adaptive-bitrate playback to an Astro 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 Astro server.

This guide uses the FastPix Astro Uploader, which is an Astro native component, so no React or other UI framework is required. If you’d rather work at a lower level, the quickstart covers the same workflow using the API directly, and resumable uploads for web covers the underlying @fastpix/resumable-uploads engine.


How it works

The upload workflow is straightforward:

  1. Your Astro server creates a signed upload URL using the FastPix Node SDK.
  2. The Astro 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:

  • An Astro 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-astro-project
$npm install @fastpix/fastpix-node @fastpix/fp-astro-uploader @fastpix/fp-player

The Node SDK talks to the FastPix API from your server, the Astro Uploader provides the upload interface, and the FastPix Player plays uploaded videos.


Configure Astro

Creating upload URLs and retrieving media happen on the server, so Astro must run in server mode instead of generating a fully static site.

Before configuring Astro, install the adapter that matches your deployment environment. This guide uses @astrojs/node for local development; see the full list of supported adapters if you’re deploying elsewhere:

$npm install @astrojs/node

Update astro.config.mjs:

1import { defineConfig } from "astro/config";
2import node from "@astrojs/node";
3
4export default defineConfig({
5 adapter: node({ mode: "standalone" }),
6 vite: {
7 optimizeDeps: {
8 exclude: ["@fastpix/fp-astro-uploader"],
9 },
10 },
11});

Excluding @fastpix/fp-astro-uploader from dependency optimization ensures the uploader’s client runtime is loaded correctly.

Add your credentials to .env:

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

WARNING: Do not prefix these with PUBLIC_. Astro exposes every PUBLIC_* variable to the browser, which would publish your Secret Key.

Create the SDK client in its own module:

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

Create an upload URL

Signed upload URLs are created with your Secret Key, so this belongs on the server. An API route gives you an endpoint the browser can call:

src/pages/api/upload-url.ts

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

src/pages/api/upload-url.js

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

export const prerender = false is required. Without it Astro tries to run this route at build time, when there’s no request to respond to.

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.

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

Render <FastPixUploader> and give it a function that mints a URL when a file is picked:

src/pages/upload.astro

1---
2import { FastPixUploader } from "@fastpix/fp-astro-uploader";
3---
4
5<FastPixUploader id="up" accept="video/*" />
6
7<script>
8 import { getUploader } from "@fastpix/fp-astro-uploader/client";
9
10 const uploader = await getUploader("#up");
11
12 uploader.endpoint = async () => {
13 const response = await fetch("/api/upload-url", { method: "POST" });
14 const { url } = await response.json();
15 return url;
16 };
17</script>

That’s a working uploader: drag and drop, a file picker, progress, and pause, resume, and cancel controls. Styles ship with the component. There’s no stylesheet to import.

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

The resolver is assigned from a <script> rather than passed as a prop because props are serialized when Astro renders on the server, and functions don’t serialize. getUploader waits for the element to be defined and upgraded, then hands you the element itself; endpoint is a property on it.


Build your own layout instead

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

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

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


Respond to upload events

The uploader emits DOM events on the element getUploader returns: fastpix-upload-start, fastpix-progress, fastpix-success, fastpix-error, and more:

1uploader.addEventListener("fastpix-success", () => {
2 /* the bytes are delivered; encoding continues */
3});

fastpix-success fires when the bytes finish uploading. The media still has to be encoded before it can play, which is what the next section covers.


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 API route:

src/pages/api/fastpix/webhook.ts

1import type { APIRoute } from "astro";
2
3export const prerender = false;
4
5export const POST: APIRoute = async ({ request }) => {
6 const { type, data } = await request.json();
7
8 if (type === "video.media.ready") {
9 /* data.id is the mediaId, data.playbackIds[0].id is the playback ID.
10 Store them against your own record here. */
11 await markVideoReady(data.id, data.playbackIds[0].id);
12 }
13
14 return Response.json({ message: "ok" });
15};

src/pages/api/fastpix/webhook.js

1export const prerender = false;
2
3export const POST = async ({ request }) => {
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};

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

Fetch the media in the page frontmatter. That runs on the server, so your credentials stay there and only the playback ID reaches the browser. <fastpix-player> is a web component, so it goes straight into your markup:

src/pages/play/[mediaId].astro (TypeScript)

1---
2import { fastpix } from "../../lib/fastpix";
3
4export const prerender = false;
5
6const { mediaId } = Astro.params;
7
8/* Read the playback ID from your own database, or from FastPix like below. */
9const media = await fastpix.manageVideos.get({ mediaId: mediaId! });
10
11if (!("data" in media)) return new Response("Media not found.", { status: 404 });
12
13const playbackId = media.data!.playbackIds![0].id!;
14---
15
16<fastpix-player playback-id={playbackId} stream-type="on-demand"/>
17
18<script>
19 import "@fastpix/fp-player";
20</script>

src/pages/play/[mediaId].astro (JavaScript)

1---
2import { fastpix } from "../../lib/fastpix";
3
4export const prerender = false;
5
6const { mediaId } = Astro.params;
7
8/* Read the playback ID from your own database, or from FastPix like below. */
9const media = await fastpix.manageVideos.get({ mediaId });
10
11if (!("data" in media)) return new Response("Media not found.", { status: 404 });
12
13const playbackId = media.data.playbackIds[0].id;
14---
15
16<fastpix-player playback-id={playbackId} stream-type="on-demand"/>
17
18<script>
19 import "@fastpix/fp-player";
20</script>

The player import lives in a <script>, which Astro only runs in the browser. It registers a custom element and would fail during server rendering.

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

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

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

Cannot use server-rendered pages without an adapter.

API routes and pages with export const prerender = false run on demand. Install an adapter such as @astrojs/node and set it in astro.config.mjs.

getUploader: the matched element is not an <fastpix-uploader>.

Your script and the component are using different copies of the client runtime. Add optimizeDeps: { exclude: ['@fastpix/fp-astro-uploader'] } to the vite section of astro.config.mjs.

Invalid config: endpoint must be a non-empty string or a function.

The resolver was never assigned, usually because the getUploader call above it threw. Check the browser console for that error first.

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 POST to an API route returns 403 from curl but works in the browser.

Astro checks the Origin header on form-style requests. A browser sends it automatically; add -H "Origin: http://localhost:4321" when testing by hand.

Chunk size is rejected.

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


What’s next