September 17, 2026

Why video in Astro needs an adapter

Santhosh Lingabalan
Santhosh Lingabalan
Content & Brand Marketer - GTM

Cannot use server-rendered pages without an adapter is Astro pointing at a real architectural fact rather than a missing dependency. Astro's default output is a folder of static files, generated once at build time and served by anything. A signed upload URL can't be generated that way, because it has to be minted per request with a credential that never reaches the browser. So the endpoint that mints it needs a server, and in Astro a server is something you opt into.

TL;DR

Astro builds static files by default, so an API route has no runtime to run in. Install an adapter matching your deployment target and set it in astro.config.mjs. Then add export const prerender = false to every route that must run per request. That means the upload endpoint, the webhook handler, and any page that fetches a playback ID per visit. That opt-out is per route, so your marketing pages stay static and CDN-served. Keep credentials unprefixed, because Astro exposes every PUBLIC_ variable to the browser. Playback of a public video needs no adapter at all.

Why Astro builds a static site by default

Astro generates HTML at build time so pages ship with little or no JavaScript and can be served from a CDN with no runtime. That's the whole point of the framework.

Most of a content site works perfectly that way. The parts that can't are the parts that depend on who is asking or on when they ask, and a signed upload URL is both.

What the route doesWorks statically
Renders a blog postYes
Renders a public video's playback IDYes
Mints a signed upload URLNo, it is per request
Receives a webhook POSTNo, a static file cannot accept one
Mints a signed playback tokenNo, it expires

What you need before you start

  • An Astro project, version 7.
  • A FastPix account with an Access Token ID and Secret Key from activate your account.
  • A deployment target, because the adapter you install has to match it.

Install the Astro adapter for your deployment target

An adapter is what teaches Astro to produce a server build for a particular runtime. @astrojs/node is the one for local development, and for running your own Node process:

bash
npm install @astrojs/node
javascript
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  vite: {
    optimizeDeps: {
      exclude: ["@fastpix/fp-astro-uploader"],
    },
  },
});

The optimizeDeps exclusion in that config has nothing to do with the adapter, and it's easy to skip past. Vite pre-bundles dependencies in development, and the uploader's client runtime has to be a single shared instance, so the documented setup keeps it out of the pre-bundle.

If you deploy somewhere other than a Node process, install that platform's adapter instead. The rest of this article is identical either way.

prerender = false is per route, and it is load-bearing

Installing the adapter doesn't make everything dynamic. Astro still prerenders routes unless a route opts out, which keeps the static pages static.

Every route that runs per request needs the export:

typescript
// src/pages/api/upload-url.ts
import type { APIRoute } from "astro";
import { fastpix } from "../../lib/fastpix";

export const prerender = false;

export const POST: APIRoute = async () => {
  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 });
};

Without that line, Astro tries to run the route at build time. There's no request to respond to. Every option on the call 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. The webhook route needs it for the same reason, and verifying that webhook is its own job. So does any playback page that fetches a media's playback ID per visit rather than baking one in.

Your marketing pages stay static and CDN-served while three routes run on a server. That beats turning the whole site dynamic to support an upload form.

Why PUBLIC_ publishes your Astro Secret Key

Astro exposes every PUBLIC_-prefixed environment variable to the browser, and prefixing is how people usually make a value "visible" when the code can't see it.

The Secret Key must not be one of them. Prefixing it inlines the credential into the client bundle at build time, so any code path that reads it puts the literal string in a file served to every visitor.

bash
# .env
FASTPIX_USERNAME=your-access-token-id   # server only
FASTPIX_PASSWORD=your-secret-key        # server only

Read them through import.meta.env in a server module:

typescript
// src/lib/fastpix.ts
import { Fastpix } from "@fastpix/fastpix-node";

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

import.meta.env leaves the discipline to you: nothing stops a component from reading the same variable. If you want the build to enforce it, declare the variable through astro:env instead:

javascript
// astro.config.mjs
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  env: {
    schema: {
      FASTPIX_USERNAME: envField.string({ context: "server", access: "secret" }),
      FASTPIX_PASSWORD: envField.string({ context: "server", access: "secret" }),
    },
  },
});

Import them from astro:env/server after that. A secret declared this way is never bundled for the client, and importing it into client code is an error rather than a leak. That's the same guarantee SvelteKit gives through $env/static/private, and it's worth the extra config for a Secret Key.

Why an Astro API route returns 403 from curl

One Astro behaviour produces a confusing report during testing and nowhere else.

Astro checks the Origin header on form-style POST requests. A browser sends it automatically, so the app works. curl doesn't, so a hand-rolled test of the same endpoint returns 403 and looks like an authentication failure.

bash
curl -X POST http://localhost:4321/api/upload-url -H "Origin: http://localhost:4321"

Worth knowing before you spend an afternoon on a route that was never broken.

Astro video playback usually needs no adapter

The adapter requirement is about minting credentials and receiving posts. A public video's playback ID is neither.

If the playback ID is known at build time, a static page can render <fastpix-player> with no adapter at all. The player takes the ID and talks to the CDN directly. That's the arrangement for a marketing page with an embedded demo, and it keeps that page fully static.

The player import goes in a <script> tag, which Astro only runs in the browser, because the element registers itself against customElements and would put the player bundle in every server-rendered page. Where playback does need a server is private or DRM-protected video, which requires a signed token minted per request.

Switch one Astro route to server rendering

Install the adapter that matches your deployment, add export const prerender = false to src/pages/api/upload-url.ts, and read credentials with import.meta.env and no PUBLIC_ prefix. Every other page in your Astro site stays static. The free plan covers 10 videos and 100K streaming minutes a month, no credit card, so the route can be proven against a real upload before you pick a deployment target.

Frequently Asked Questions (FAQs)

Why does Astro say it cannot use server-rendered pages without an adapter?

Because the route has export const prerender = false, or is an API route, and both need a server at runtime. Astro's default output is static files with no runtime behind them. Install an adapter matching your deployment target, such as @astrojs/node, and set it in astro.config.mjs.

Do I need prerender = false on every route?

No, only on routes that must run per request: API routes, webhook handlers, and pages whose content depends on who is asking or when. Everything else stays prerendered and CDN-served, which is the arrangement you want. The per-route opt-out is why installing an adapter does not make the whole site dynamic.

Can I put FastPix credentials in PUBLIC_ variables in Astro?

No. Astro exposes every PUBLIC_-prefixed variable to the browser, so prefixing the Secret Key inlines it into the client bundle at build time and ships it to every visitor. Keep credentials unprefixed and read them with import.meta.env inside server code only.

Why does my Astro API route return 403 from curl but work in the browser?

Because Astro checks the Origin header on form-style POST requests, and a browser sends it automatically while curl does not. Add -H "Origin: http://localhost:4321" to your curl command and the route responds normally. It is not an authentication failure.

Do I need an adapter just to play a video in Astro?

Not for a public video whose playback ID is known at build time. A static page can render the player and it will talk to the CDN directly. You need a server for minting signed upload URLs, receiving webhooks, and signing tokens for private or DRM-protected playback.

Share

Stay Ahead of Video
Streaming Trends

Start shipping video today.