komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Server Rendering••By Komal Nakrani

Astro SSR: Server Rendering, When and How

Astro SSR explained: adapters, on-demand pages with prerender = false, server islands, actions, middleware and sessions in Astro 7, and when to stay static.

#Astro#SSR#On-Demand Rendering#Server Islands#Actions

Astro SSR, which the Astro docs call on-demand rendering, means rendering a page on the server for each request instead of once at build time. You turn it on by installing an adapter for your host (Node.js, Cloudflare, Netlify or Vercel) and either marking individual pages with export const prerender = false or setting output: 'server' to make on-demand the default. Everything else in the site can stay static.

This guide covers how rendering modes work in Astro 7, what adapters do, and the server features built on top: server islands, actions, middleware and sessions. It ends with a short test for when you should stay fully static.

Key Takeaways

  • Astro is static by default. An adapter is required before any page can render on request.
  • export const prerender = false makes one page on-demand. output: 'server' flips the default, and prerender = true opts pages back into static.
  • Server islands (server:defer) render one component per request inside an otherwise static, cacheable page.
  • Actions give you type-safe server functions for forms and mutations, validated with Zod.
  • Middleware runs before every on-demand request; sessions store per-visitor data server-side.

Static, on-demand and mixed

Astro has two output values:

output Default for pages Opt out per page
'static' (default) Prerendered at build export const prerender = false
'server' Rendered on request export const prerender = true

Both support mixing. Pick the default that matches most of your pages:

  • A marketing site with one account page: keep 'static', mark the account page prerender = false.
  • An app-like site with a few static pages: use 'server', mark the static ones prerender = true.

The hybrid rendering tip shows both patterns with config.

What changes on an on-demand page:

  • Astro.request is the real incoming request, with headers.
  • Astro.cookies can read and set cookies.
  • Astro.params comes from the URL; no getStaticPaths().
  • You can return Astro.redirect() or a Response with any status.
---
// src/pages/dashboard.astro
export const prerender = false;

const token = Astro.cookies.get('session')?.value;
if (!token) return Astro.redirect('/login/');

const res = await fetch('https://api.example.com/me', {
  headers: { Authorization: `Bearer ${token}` },
});
const user = await res.json();
---
<h1>Welcome back, {user.name}</h1>

Adapters

An adapter turns Astro’s server build into whatever your host runs: a Node.js server, a Cloudflare Worker, a Netlify Function or a Vercel Function. Install one with astro add:

npx astro add node        # your own server or container
npx astro add cloudflare  # Cloudflare Workers / Pages
npx astro add netlify
npx astro add vercel

This installs the package and adds it to config:

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

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
});

After a build, static pages are in dist/client/ (or the host’s equivalent) and the server entry handles the rest. Adapters also expose platform features: Cloudflare bindings for KV, D1 and R2, Netlify edge functions, Vercel image optimisation. Read your adapter’s docs for those.

The deploy guide compares the hosts these adapters target.

Choosing an adapter

Adapter Runs on Good to know
@astrojs/node Your server, a container, a VPS standalone mode runs its own server; middleware mode plugs into Express or similar
@astrojs/cloudflare Cloudflare Workers Access to KV, D1, R2 and other bindings; Workers runtime, not full Node.js
@astrojs/netlify Netlify Functions (and edge middleware) Works with Netlify’s image CDN and redirects
@astrojs/vercel Vercel Functions Works with Vercel’s image optimisation and ISR-style caching options

Pick the one for where you already host, and check its docs for runtime limits. Edge and Workers runtimes don’t support every Node.js API, so libraries that rely on the filesystem or native modules may need alternatives there.

Server islands

Often only one part of a page is personal: a cart badge, an avatar, “recommended for you”, live stock. Rendering the whole page on demand for that slows down the 95% that’s the same for everyone and stops CDN caching.

Server islands fix this. Add server:defer to an Astro component:

---
// src/pages/product/[id].astro (static, prerendered)
import CartBadge from '../../components/CartBadge.astro';
import StockLevel from '../../components/StockLevel.astro';
---
<header>
  <CartBadge server:defer>
    <span slot="fallback" class="badge">…</span>
  </CartBadge>
</header>

<StockLevel server:defer productId={Astro.params.id}>
  <p slot="fallback">Checking stock…</p>
</StockLevel>

Astro builds the page statically with the fallbacks in place. In the browser, a small script requests each island’s HTML from the server and swaps it in. The island component runs per request, so it can read cookies and query live data:

---
// src/components/CartBadge.astro
const cartId = Astro.cookies.get('cart')?.value;
const count = cartId ? await getCartCount(cartId) : 0;
---
<a href="/cart/" class="badge">{count}</a>

Props passed to a server island must be serialisable, and are encrypted in the request. Server islands need an adapter but not on-demand pages: the page itself can stay static and cached. The server islands guide covers caching headers for island responses.

Actions

Actions are server functions you call from forms or client code, with input validated by Zod and typed end to end. Define them in src/actions/index.ts:

// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';

export const server = {
  subscribe: defineAction({
    accept: 'form',
    input: z.object({
      email: z.email(),
    }),
    handler: async ({ email }) => {
      const ok = await addToNewsletter(email);
      if (!ok) throw new ActionError({ code: 'BAD_REQUEST', message: 'Could not subscribe.' });
      return { email };
    },
  }),
};

Use it from an HTML form on an on-demand page:

---
// src/pages/newsletter.astro
import { actions } from 'astro:actions';
export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
---
{result && !result.error && <p>Thanks, {result.data.email} is subscribed.</p>}
{result?.error && <p>{result.error.message}</p>}

<form method="POST" action={actions.subscribe}>
  <input type="email" name="email" required />
  <button>Subscribe</button>
</form>

The form works without JavaScript. From client code, call await actions.subscribe(formData) and get back { data, error }. Compared to hand-written endpoints, actions give you validation, typed results and error codes for less code. The Actions guide covers redirects after submit and progressive enhancement.

For a static site without an adapter, form submissions need a third-party endpoint instead; the newsletter signup tip shows both approaches.

Middleware

Middleware runs before every on-demand page and endpoint, and can modify the request, the response, or share data through context.locals:

// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';

const auth = defineMiddleware(async (context, next) => {
  const token = context.cookies.get('session')?.value;
  context.locals.user = token ? await verify(token) : null;

  if (context.url.pathname.startsWith('/dashboard') && !context.locals.user) {
    return context.redirect('/login/');
  }
  return next();
});

const headers = defineMiddleware(async (_context, next) => {
  const response = await next();
  response.headers.set('X-Frame-Options', 'DENY');
  return response;
});

export const onRequest = sequence(auth, headers);

Type locals in src/env.d.ts:

declare namespace App {
  interface Locals {
    user: { id: string; name: string } | null;
  }
}

For prerendered pages, middleware runs once at build time, so request-specific logic (cookies, headers) does nothing there.

Sessions

Sessions store data per visitor on the server, keyed by a cookie. Access them through Astro.session in pages and context.session in actions, endpoints and middleware:

---
export const prerender = false;
const cart = (await Astro.session?.get('cart')) ?? [];
---
<p>{cart.length} items in your cart</p>
// in an action handler
handler: async ({ productId }, context) => {
  const cart = (await context.session?.get('cart')) ?? [];
  context.session?.set('cart', [...cart, productId]);
  return { count: cart.length + 1 };
},

Sessions need a storage driver. Some adapters configure one by default (for example, Node uses the filesystem). Otherwise set one in config with the session option and a driver from sessionDrivers, such as Redis or Cloudflare KV.

Cookies, headers and caching

On on-demand pages you control the response:

---
export const prerender = false;
Astro.response.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
---

A short CDN cache with stale-while-revalidate gives you near-static speed on pages that change every few minutes. Personal pages should be private, no-store.

Worked example: a members area on a static site

A common real-world case: a marketing site and blog that stay static, plus a /members/ area behind a login. Here’s how the pieces fit.

1. Add an adapter, keep the default output.

npx astro add node

Everything stays prerendered unless a page opts out.

2. Protect the area with middleware.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  if (!context.url.pathname.startsWith('/members')) return next();

  const userId = await context.session?.get('userId');
  if (!userId) {
    return context.redirect(`/login/?next=${encodeURIComponent(context.url.pathname)}`);
  }
  context.locals.userId = userId;
  return next();
});

3. Log in with an action that writes the session.

// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';

export const server = {
  login: defineAction({
    accept: 'form',
    input: z.object({ email: z.email(), password: z.string().min(8) }),
    handler: async ({ email, password }, context) => {
      const user = await verifyCredentials(email, password); // your auth provider
      if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Wrong email or password.' });
      await context.session?.regenerate();
      context.session?.set('userId', user.id);
      return { ok: true };
    },
  }),
};

Regenerating the session ID on login prevents session fixation.

4. Mark member pages as on-demand.

---
// src/pages/members/index.astro
export const prerender = false;
const { userId } = Astro.locals;
const downloads = await getDownloadsFor(userId);
---
<h1>Your downloads</h1>
<ul>{downloads.map((d) => <li><a href={d.url}>{d.name}</a></li>)}</ul>

The result: the blog and marketing pages are still static files on a CDN, and only /members/*, /login/ and the action endpoint run on the server. For production authentication, use an established auth library or provider rather than hand-rolled password checks; the structure above stays the same.

Error handling

On-demand pages can fail in ways static pages can’t: an API times out, a database is down. Plan for it:

  • Custom error pages. src/pages/500.astro renders on unhandled server errors and receives the error as a prop. 404.astro renders for unmatched routes and for empty 404 responses.
  • Timeouts on fetches. Pass signal: AbortSignal.timeout(3000) to fetch() so a slow upstream doesn’t hold the request open.
  • Fallback content. For non-critical data, catch the error and render without it rather than failing the whole page.
---
export const prerender = false;
let reviews = [];
try {
  const res = await fetch('https://api.example.com/reviews', { signal: AbortSignal.timeout(3000) });
  reviews = await res.json();
} catch {
  // render the page without reviews
}
---

Running on-demand pages locally

astro dev renders on-demand pages per request, like production. To test the built server output:

npm run build
npx astro preview      # works with adapters that support preview, such as Node

Some adapters emulate the platform locally. The Cloudflare adapter, for example, can run your code in the Workers runtime during development so bindings like KV behave as they will in production. Check your adapter’s docs for its local workflow.

When to stay static

On-demand rendering costs something: a server or function to run, cold starts on some platforms, more failure modes, and slower first bytes than a CDN file. Before adding an adapter, check:

  1. Does the content differ per visitor? If not, it can be static.
  2. Does it change more often than you can rebuild? A two-minute rebuild on a CMS webhook covers most blogs and catalogues.
  3. Is only one component dynamic? Use a server island and keep the page static.
  4. Is it just a form? A third-party form endpoint may be enough on a static site.

If all four answers point to static, stay static. Most marketing sites, docs and portfolios never need an adapter.

Static by design

Komal themes are fully static Astro 7 projects with no adapter, so they deploy anywhere. When you need a server feature, add an adapter to the theme and turn on individual pages. Browse Astro themes or read the Astro configuration docs.

FAQ

Does Astro support SSR?

Yes. Install an adapter and mark pages with export const prerender = false, or set output: 'server' to render all pages on demand by default.

What is the difference between output ‘static’ and ‘server’ in Astro?

They set the default. With 'static', pages are prerendered unless they opt out. With 'server', pages render on request unless they opt in to prerendering. Both allow mixing once an adapter is installed.

What are server islands in Astro?

Components marked server:defer that render on the server per request after the page loads, while the rest of the page stays static and cacheable. They need an adapter.

Do Astro Actions work on static sites?

Actions run on the server, so they need an adapter. The page using the action must be on-demand when you submit with a plain HTML form; calling the action from client code works from static pages too.

Which Astro adapter should I use?

The one for your host: @astrojs/cloudflare, @astrojs/netlify, @astrojs/vercel, or @astrojs/node for your own server or container.