Skip to content

SSR & Adapters

On-demand rendering runs your component script per request, so you can read request-time data. To run server code you install an adapter for your target platform and add it to the config:

astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
adapter: node({ mode: 'standalone' }), // or cloudflare(), vercel(), netlify()
});

With an adapter present, routes render on demand where they need to (or when you set export const prerender = false on a route). Without one, any on-demand feature fails to build with “an adapter is required.”

sequenceDiagram
  participant B as Browser
  participant S as Astro server (adapter)
  participant C as Component script
  B->>S: GET /dashboard
  S->>C: run script with request context
  C->>C: read cookies, user, query params
  C-->>S: HTML + response settings
  S-->>B: Response (status, headers, body)
A request flows through render to a response

Two settings decide, per route, whether it’s built once or rendered per request: the project-wide output default, and a page’s own prerender export, which always wins over the default.

  • output: 'static' (the default) — every route is prerendered to static HTML at build time, unless a page opts into on-demand rendering with export const prerender = false (and an adapter is installed).
  • output: 'server' — every route renders on demand by default; a page opts out into a static file with export const prerender = true.
---
// src/pages/about.astro — always static, even under output: 'server'
export const prerender = true;
---
<h1>About</h1>
---
// src/pages/dashboard.astro — always on-demand, even under output: 'static'
export const prerender = false;
const user = Astro.locals.user;
---
{user && <h1>Hi, {user.name}</h1>}

Node adapter modes: standalone vs middleware, both proven

Section titled “Node adapter modes: standalone vs middleware, both proven”

@astrojs/node ships two distinct modes, and they produce a genuinely different dist/server/entry.mjs:

astro.config.mjs
import node from '@astrojs/node';
export default defineConfig({
adapter: node({ mode: 'standalone' }), // or { mode: 'middleware' }
});

Standalone is a complete, self-running server — node ./dist/server/entry.mjs starts listening on its own:

$ node ./dist/server/entry.mjs
04:47:44 [@astrojs/node] Server listening on http://127.0.0.1:4351

Middleware mode builds no listener at all — the same entrypoint instead exports a raw handler, meant to be mounted into a server you already run (Express, Fastify, plain http):

$ node -e "import('./dist/server/entry.mjs').then(m => console.log(Object.keys(m)))"
[ 'handler', 'options', 'startServer' ]

Proven by mounting that exact handler into a bare Node http server and confirming a real route still resolves through it:

// host.mjs — a minimal example of what "mounting into an existing server" means
import http from 'node:http';
import { handler as ssrHandler } from './dist/server/entry.mjs';
const server = http.createServer((req, res) => {
ssrHandler(req, res, (err) => { if (err) { res.statusCode = 500; res.end(String(err)); } });
});
server.listen(4351);
$ curl -s http://127.0.0.1:4351/api/health
{"ok":true,"hasSecret":true}

Reach for standalone when Astro is the whole server; reach for middleware when Astro’s output needs to live inside an app you already have (an existing Express server with other routes, custom TLS termination, etc.) — and remember middleware mode does no static file serving on its own, so dist/client/ still needs to be served separately.

Inside an on-demand page or endpoint, the global Astro object exposes the request:

---
export const prerender = false;
const url = Astro.url; // a URL object
const q = url.searchParams.get('q'); // query params
const method = Astro.request.method; // the raw Request
const token = Astro.cookies.get('session')?.value; // typed cookie access
const { id } = Astro.params; // dynamic route params
---
<p>Searching for {q}</p>
  • Astro.request is the standard Request — headers, method, and await Astro.request.formData() or .json() for the body.
  • Astro.url is a parsed URL — the pathname and searchParams.
  • Astro.params holds dynamic segments ([id]).
  • Astro.cookies reads and writes cookies with a typed API (.get, .set, .delete, .has).

By default a page returns its HTML with a 200 status. You control the response through Astro.response (set status and headers) or by returning a Response directly:

---
export const prerender = false;
const user = Astro.cookies.get('session')?.value;
// Not logged in? Redirect.
if (!user) return Astro.redirect('/login');
// Set a custom header on the HTML response.
Astro.response.headers.set('Cache-Control', 'private, max-age=0');
---
<h1>Your dashboard</h1>

Astro.redirect('/login') returns a redirect Response; Astro.response.headers mutates the outgoing headers; and you can return new Response(...) to take full control (a 404 page, a custom status). This is the same Request/Response model the whole web platform uses — Astro doesn’t invent its own.

astro:env: typed environment variables, checked at the boundary

Section titled “astro:env: typed environment variables, checked at the boundary”

Plain import.meta.env.WHATEVER is untyped and doesn’t stop a server secret from being read in client code. astro:env schema-checks every variable and, more importantly, enforces where each one is allowed to be read from — along two independent axes:

astro.config.mjs
import { defineConfig, envField } from 'astro/config';
export default defineConfig({
env: {
schema: {
PUBLIC_SITE_NAME: envField.string({ context: 'client', access: 'public', default: 'Probe' }),
API_URL: envField.string({ context: 'server', access: 'public', default: 'https://api.example.com' }),
DB_PASSWORD: envField.string({ context: 'server', access: 'secret' }),
},
},
});
context access: 'public' access: 'secret'
client Readable from astro:env/client — inlined as a literal into the client bundle Not a valid combination — Astro refuses to configure it, there is no safe way to ship a secret to the browser
server Readable from astro:env/server, server-only code Readable from astro:env/server only, never bundled to the client

A client/public variable really is inlined as a plain literal — proven by grepping the actual compiled client script for a page that imports PUBLIC_SITE_NAME:

$ grep -o 'var e=.*Probe.*;' dist/server/chunks/*.mjs
var e=`Probe`;document.getElementById(`client-side`).textContent=e;

Try to import the server-only module from a client <script> and the build refuses outright — not a runtime undefined, a real compiler error:

<script>
import { DB_PASSWORD } from 'astro:env/server';
console.log(DB_PASSWORD);
</script>
[ServerOnlyModule] The "astro:env/server" module is only available server-side.
Error reference:
https://docs.astro.build/en/reference/errors/server-only-module/

And a required secret with no value set doesn’t silently become undefined either — the route that imports it fails with the schema’s own diagnostic the moment it’s actually used:

$ curl -s http://127.0.0.1:4351/api/health
(500)
[ERROR] EnvInvalidVariables: The following environment variables defined in `env.schema` are invalid:
- DB_PASSWORD is missing

(You can force this check to happen for every configured secret regardless of whether it’s imported, with env: { validateSecrets: true } — useful for catching a missing production secret in CI rather than on the first request that happens to need it.)

When reviewing generated rendering/config/env code, check for:

  • Does the config set output: 'hybrid'? That value doesn’t exist since Astro v5 (proven above) — it’s a stale term from older training data or docs, not something the compiler accepts.
  • Does a page read Astro.cookies, Astro.clientAddress, or anything else request-specific while also carrying (or inheriting) prerender = true? Those reads need a live request that a prerendered page never has.
  • Is a secret ever read through plain import.meta.env.SOMETHING instead of astro:env/server, losing both the schema check and the client/server boundary enforcement proven above?
  • Does client-side code import from astro:env/server directly, expecting it to “just be undefined” if misused, rather than the hard build error proven above?
  • Is node ./dist/server/entry.mjs assumed to always start a listener? In middleware mode it does not — proven above, it exports a handler you must mount yourself.

🐛 Spot the bug

astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'hybrid',
adapter: node({ mode: 'standalone' }),
});
Show the answer

output: 'hybrid' was removed in Astro v5 — this config fails validation on a current Astro install, it isn’t a quietly-accepted legacy alias. The behavior the author wants (“static by default, with some on-demand pages”) is exactly what output: 'static' already does, using per-page export const prerender = false for the pages that need the server — proven earlier in this lesson. The fix is output: 'static' (or simply omitting output, since it’s the default) with prerender set per page as needed; output: 'server' is the other valid choice if most pages should be on-demand instead.

output is a default, not a hard partition — every route’s actual rendering mode is prerender, which always wins, whichever way it’s set explicitly or inherited from the project default. An adapter is a translation layer between that per-request rendering and one specific runtime’s request/response shape (Node’s http, a Cloudflare fetch handler, and so on) — which is exactly why context.clientAddress, Astro.cookies writes, and live env reads all need a route that’s actually on-demand, not just an adapter installed somewhere in the config.

output: 'static' (default) output: 'server'
Per-page override prerender = false opts a page into the server prerender = true opts a page out to static
Best starting point Most sites — few pages need the server An app where most routes are on-demand
Adapter still required Yes, for any on-demand page Yes, always
Node standalone Node middleware
Startup Self-listening, proven above No listener — you mount handler yourself, proven above
Static assets Served automatically You serve dist/client/ yourself
Best for Astro is the whole server Astro lives inside an existing app
  • “output: 'hybrid' is the mode for mixing static and on-demand pages.” Proven above: that value doesn’t exist since v5 — 'static' with per-page prerender = false is that exact mode, under its current name.
  • “An adapter alone is enough for Astro.clientAddress/cookies/live env reads anywhere in the project.” Those still need the specific route to be on-demand — a prerendered page with an adapter installed elsewhere in the project still fails, proven in the endpoints lesson.
  • “node ./dist/server/entry.mjs always starts a server.” Only in standalone mode — proven above, middleware mode exports a handler with no listener at all.
  • “A secret env var that’s just not set becomes undefined at runtime, like a normal env var.” Proven above: a schema’d secret with no value throws a real EnvInvalidVariables error the moment it’s read, not a silent undefined.
What must be configured for on-demand rendering to work?
output is 'static' and a specific page has export const prerender = false. What actually happens to that page?
A config sets output: 'hybrid'. What happens on a current Astro install, proven in this lesson?
This output is from a Node adapter build in which mode, and what does it mean?
$ node -e "import('./dist/server/entry.mjs').then(m => console.log(Object.keys(m)))"
[ 'handler', 'options', 'startServer' ]
A schema declares DB_PASSWORD as envField.string({ context: 'server', access: 'secret' }) with no default, and the deployed process has no DB_PASSWORD set. What is the real, proven behavior when a route imports it?
What happens if client-side code tries import { DB_PASSWORD } from 'astro:env/server'?