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 withprerender = 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.
| Adapter | Package | Runtime | Best for |
|---|---|---|---|
| Node | @astrojs/node | Long-running Node server | VPS, Docker, your own infra |
| Vercel | @astrojs/vercel | Serverless / Edge functions | Vercel-hosted projects |
| Netlify | @astrojs/netlify | Netlify Functions / Edge | Netlify-hosted projects |
| Cloudflare | @astrojs/cloudflare | Workers (V8 isolates) | Edge-first, global low latency |
Warning: Edge runtimes (Cloudflare Workers, Vercel/Netlify Edge) are not full Node. Native Node APIs like
fs,cryptobuiltins, 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 intoprerender = 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 setsoutputfor 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-Controlheaders from on-demand routes that return cacheable data to offload work to the CDN. - Test the production server locally (
npm run buildthen run the entry) before deploying to catch runtime-only failures early.