Skip to content
Astro as deployment 4 min read

Deploying SSR Apps

Astro ships zero JavaScript by default and prerenders every route to static HTML — but the moment you need request-time logic (auth, personalized content, form handlers, API routes), you switch a route or your whole site to on-demand rendering. To run that server code in production you must install an adapter that teaches Astro how to build for your specific host. This page walks through pairing output: 'server' with the right adapter and deploying the result.

How SSR rendering works in Astro

Modern Astro (4/5) renders per route. A page is static by default; you opt a single route into on-demand rendering by exporting prerender = false, or you flip the whole project with output: 'server'. Either way, the build produces a server entrypoint that runs your code on each request while still keeping islands and zero-JS-by-default for the parts that don’t need interactivity.

---
// src/pages/dashboard.astro
export const prerender = false; // render this route on demand

const user = await fetch(`https://api.example.com/me`, {
  headers: { cookie: Astro.request.headers.get("cookie") ?? "" },
}).then((r) => r.json());
---

<h1>Welcome back, {user.name}</h1>
<p>You have {user.unreadCount} new messages.</p>

Tip: Prefer a static-first project (output: 'static', the default) and mark only the routes that truly need a server with prerender = false. You get the speed of a CDN for everything else and pay for compute only where it matters.

Choosing and installing an adapter

An adapter is an Astro integration that emits the correct server bundle and entry format for a host. Install it with the astro add command, which wires up astro.config.mjs and sets the output mode for you.

# Pick the one that matches your host
npx astro add node
npx astro add vercel
npx astro add netlify
npx astro add cloudflare

After running astro add, your config will look similar to this:

// astro.config.mjs
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  output: "server",
  adapter: vercel(),
});

Adapter reference

Each platform has different runtime characteristics. Match the adapter to where you actually run.

AdapterPackageRuntimeBest for
Node@astrojs/nodeLong-running Node serverVPS, Docker, your own infra
Vercel@astrojs/vercelServerless / Edge functionsVercel-hosted projects
Netlify@astrojs/netlifyNetlify Functions / EdgeNetlify-hosted projects
Cloudflare@astrojs/cloudflareWorkers (V8 isolates)Edge-first, global low latency

Warning: Edge runtimes (Cloudflare Workers, Vercel/Netlify Edge) are not full Node. Native Node APIs like fs, crypto builtins, or large npm packages may be unavailable. Verify your dependencies before committing to an edge target.

Deploying with the Node adapter

The Node adapter is the most portable — it produces a self-contained server you can run anywhere, including a Docker container. Use mode: 'standalone' to get a runnable HTTP server out of the box.

// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
});

Build and run it:

npm run build
node ./dist/server/entry.mjs

Output:

12:04:31 [@astrojs/node] Server listening on http://localhost:4321

A minimal production Dockerfile:

# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

Hybrid rendering and server endpoints

Even in output: 'server', you can prerender individual static pages back out with export const prerender = true. This hybrid approach keeps marketing pages on the CDN while keeping the app dynamic. On-demand routes can also be JSON API endpoints:

// src/pages/api/subscribe.ts
import type { APIRoute } from "astro";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const data = await request.formData();
  const email = data.get("email");

  if (typeof email !== "string" || !email.includes("@")) {
    return new Response(JSON.stringify({ error: "Invalid email" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  // persist the subscriber...
  return new Response(JSON.stringify({ ok: true }), { status: 201 });
};

Environment variables and secrets

Server routes can read secrets at request time. Use Astro’s typed env or import.meta.env, and configure the same variables in your host’s dashboard so they exist at runtime — not just at build time.

// src/pages/checkout.astro frontmatter
const stripeKey = import.meta.env.STRIPE_SECRET_KEY;

Tip: Prefix only the variables you intend to expose to the client with PUBLIC_. Anything without that prefix stays server-side and is never bundled into the browser.

Best Practices

  • Default to output: 'static' and opt individual routes into prerender = false; reach for a fully server-rendered build only when most routes need request-time data.
  • Always run astro add <adapter> rather than editing config by hand — it pins compatible versions and sets output for you.
  • Confirm your dependencies are compatible with the chosen runtime, especially for edge targets that lack full Node APIs.
  • Keep secrets out of PUBLIC_-prefixed variables and set runtime env vars in the host dashboard, not just .env.
  • Cache aggressively: send Cache-Control headers from on-demand routes that return cacheable data to offload work to the CDN.
  • Test the production server locally (npm run build then run the entry) before deploying to catch runtime-only failures early.
Last updated June 14, 2026
Was this helpful?