On-Demand Rendering per Route
Most pages on a site never change between requests, but a handful — a dashboard, a search results page, a personalized greeting — genuinely need to run on every request. Astro lets you make that decision per route with a single line: export const prerender. You ship a fast, fully static site by default and pay the cost of server rendering only on the exact pages that need it. This is the heart of Astro’s hybrid model, and it is the most common way real projects are built.
How per-route rendering works
Modern Astro (5.x) treats your project as server-first once an adapter is installed. With an adapter present, routes are rendered on-demand by default, and you opt individual routes out into static HTML by exporting prerender = true. Without an adapter, every route is prerendered at build time and the flag does nothing useful.
The control surface is one exported constant in the component frontmatter (the --- script fence) of any .astro page or in any endpoint file:
---
// src/pages/dashboard.astro
export const prerender = false; // render this page on every request
const user = await getUser(Astro.request);
---
<h1>Welcome back, {user.name}</h1>
A static page is the inverse — it is computed once at build time and served as a flat file:
---
// src/pages/about.astro
export const prerender = true; // baked at build, zero server cost
---
<h1>About DevCraftly</h1>
The value must be a statically analyzable boolean literal. Astro reads it at build time without executing your code, so export const prerender = someRuntimeValue will not work.
Choosing a project default
Your astro.config.mjs sets the baseline that individual routes override. The output option controls it:
// astro.config.mjs
import { defineConfig } from "astro";
import node from "@astrojs/node";
export default defineConfig({
output: "static", // default: routes are prerendered unless they opt out
adapter: node({ mode: "standalone" }),
});
output value | Default per route | Override with |
|---|---|---|
"static" | Prerendered at build | export const prerender = false |
"server" | Rendered on-demand | export const prerender = true |
In Astro 5, the old
output: "hybrid"mode was removed.static+ an adapter is hybrid — you simply flip the routes that need a server. Pickstaticwhen most pages are static,serverwhen most pages are dynamic.
A realistic hybrid layout
A marketing site with a small authenticated area is the classic case. Static pages prerender; the dynamic ones opt in to on-demand rendering.
---
// src/pages/blog/[slug].astro — STATIC, generated from a content collection
import { getCollection } from "astro:content";
export const prerender = true;
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
---
// src/pages/account.astro — ON-DEMAND, reads the live request
export const prerender = false;
const session = Astro.cookies.get("session")?.value;
if (!session) {
return Astro.redirect("/login");
}
const account = await fetchAccount(session);
---
<h1>Hello, {account.email}</h1>
<p>Plan: {account.plan}</p>
Note the key difference: a prerendered route can use getStaticPaths() to fan out into many pages at build time, while an on-demand route can read Astro.request, Astro.cookies, and Astro.redirect() — APIs that only make sense when a real request exists. Calling getStaticPaths() from a non-prerendered page is ignored.
Endpoints opt in too
API routes (.ts/.js files in src/pages) follow the same rule. A static endpoint emits a file at build; an on-demand one runs per request and can inspect the incoming Request.
// src/pages/api/now.ts
import type { APIRoute } from "astro";
export const prerender = false; // must run per request to be "now"
export const GET: APIRoute = ({ request }) => {
return new Response(
JSON.stringify({ now: new Date().toISOString(), url: request.url }),
{ headers: { "Content-Type": "application/json" } },
);
};
Verify what got built — prerendered routes show up as files in dist/, on-demand routes do not:
npm run build
Output:
▶ src/pages/blog/[slug].astro
├─ /blog/getting-started/index.html (+12ms)
└─ /blog/astro-5/index.html (+9ms)
λ src/pages/account.astro
λ src/pages/api/now.ts
The ▶ marker is a prerendered route written to disk; λ marks an on-demand (lambda-style) route handled by the adapter at runtime. This listing is the fastest way to confirm a route landed in the mode you intended.
Islands still apply either way
Rendering mode is independent of client-side interactivity. Whether a page is static or on-demand, it ships zero JavaScript by default, and you hydrate only the islands you mark with a client:* directive:
---
import Cart from "../components/Cart.tsx";
export const prerender = false;
---
<Cart client:load /> <!-- the only JS shipped on this page -->
The HTML shell may be static or server-rendered; the interactive island hydrates the same way regardless.
Best practices
- Default to
output: "static"and opt routes intoprerender = falseonly when they read the live request — this keeps your attack surface and server cost minimal. - Use a literal
true/false; never compute theprerendervalue, since Astro reads it without running your code. - Move authenticated, personalized, or time-sensitive pages to on-demand; leave content-collection and marketing pages static.
- After every build, scan the
▶vsλoutput to catch a route that silently fell into the wrong mode. - Keep
getStaticPaths()only on prerendered routes and request APIs (Astro.cookies,Astro.request) only on on-demand routes — mixing them is a common source of confusing build behavior. - Remember that hydration is orthogonal: a static page can have islands, and an on-demand page can still ship zero JS.