SSR & Adapters
Turning on the server
Section titled “Turning on the server”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:
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)
output and per-page prerender
Section titled “output and per-page prerender”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 withexport 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 withexport 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:
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.mjs04:47:44 [@astrojs/node] Server listening on http://127.0.0.1:4351Middleware 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" meansimport 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.
Reading the request
Section titled “Reading the request”Inside an on-demand page or endpoint, the global Astro object exposes the request:
---export const prerender = false;
const url = Astro.url; // a URL objectconst q = url.searchParams.get('q'); // query paramsconst method = Astro.request.method; // the raw Requestconst token = Astro.cookies.get('session')?.value; // typed cookie accessconst { id } = Astro.params; // dynamic route params---<p>Searching for {q}</p>Astro.requestis the standardRequest— headers, method, andawait Astro.request.formData()or.json()for the body.Astro.urlis a parsedURL— the pathname andsearchParams.Astro.paramsholds dynamic segments ([id]).Astro.cookiesreads and writes cookies with a typed API (.get,.set,.delete,.has).
Shaping the response
Section titled “Shaping the response”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:
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/*.mjsvar 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.)
Reading AI-generated code
Section titled “Reading AI-generated code”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.SOMETHINGinstead ofastro:env/server, losing both the schema check and the client/server boundary enforcement proven above? - Does client-side code import from
astro:env/serverdirectly, expecting it to “just be undefined” if misused, rather than the hard build error proven above? - Is
node ./dist/server/entry.mjsassumed to always start a listener? Inmiddlewaremode it does not — proven above, it exports a handler you must mount yourself.
🐛 Spot the bug
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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”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 |
Common Misconceptions
Section titled “Common Misconceptions”- “
output: 'hybrid'is the mode for mixing static and on-demand pages.” Proven above: that value doesn’t exist since v5 —'static'with per-pageprerender = falseis 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.mjsalways starts a server.” Only instandalonemode — proven above,middlewaremode exports a handler with no listener at all. - “A secret env var that’s just not set becomes
undefinedat runtime, like a normal env var.” Proven above: a schema’d secret with no value throws a realEnvInvalidVariableserror the moment it’s read, not a silentundefined.