Skip to main content
Technology & EngineeringAstro359 lines

Astro SSR

Server-side rendering in Astro with adapters for Node, Vercel, Netlify, Cloudflare, and Deno

Quick Summary14 lines
You are an expert in Astro server-side rendering, output modes, and adapter configuration for dynamic websites.

## Key Points

- Use hybrid rendering: pre-render as many pages as possible and server-render only pages that truly need request-time data (auth, personalization, real-time data).
- Add `export const prerender = true` to pages like marketing pages, docs, and blog posts that do not depend on request context.
- Use `Astro.cookies` instead of manually parsing the `Cookie` header. The helper handles serialization and provides a cleaner API.
- Return proper HTTP status codes. Use `Astro.redirect()` for redirects and `new Response(null, { status: 404 })` for not-found pages.
- Choose the `standalone` adapter mode for Docker deployments and `middleware` mode when integrating Astro into an existing Express or Fastify server.
- **Forgetting to install an adapter**: SSR mode without an adapter causes a build error. Every server-rendered Astro project needs an adapter for the target platform.
- **Using `Astro.request` in static pages**: Request data like headers, cookies, and client address are only available in server-rendered pages. Accessing them in a pre-rendered page throws an error.
- **Cold start latency**: Serverless adapters (Vercel, Netlify, Cloudflare) may have cold start delays. Keep SSR pages lightweight and avoid heavy initialization logic at module scope.
skilldb get astro-skills/Astro SSRFull skill: 359 lines
Paste into your CLAUDE.md or agent config

Server-Side Rendering — Astro

You are an expert in Astro server-side rendering, output modes, and adapter configuration for dynamic websites.

Overview

Astro supports three output modes: static (default), server, and hybrid. SSR enables dynamic pages that render on each request, giving you access to request headers, cookies, authentication, and real-time data without rebuilding the entire site.

Core Concepts

Output Modes

Configure the output mode in astro.config.mjs:

import { defineConfig } from 'astro/config';

// Static (default) — all pages pre-rendered at build time
export default defineConfig({
  output: 'static',
});

// Server — all pages rendered on demand by default
export default defineConfig({
  output: 'server',
});

Hybrid Rendering

In server mode, you can opt individual pages into pre-rendering:

---
// This page is pre-rendered at build time, even in server mode
export const prerender = true;
---

<h1>This page is static</h1>

In static mode, you can opt individual pages into server rendering:

---
// This page is server-rendered, even in static mode
export const prerender = false;
---

<h1>This page is dynamic</h1>

Adapters

Adapters connect Astro's SSR output to a hosting platform's runtime:

# Node.js (self-hosted, Docker)
npx astro add node

# Vercel (serverless or edge)
npx astro add vercel

# Netlify (serverless or edge)
npx astro add netlify

# Cloudflare (Workers / Pages)
npx astro add cloudflare

# Deno
npx astro add deno
// astro.config.mjs — Node adapter example
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone', // or 'middleware'
  }),
});

The Astro Global in SSR

Server-rendered pages have access to the full Astro global with request information:

---
// Available in SSR pages and endpoints
const url = Astro.url;                    // Full URL object
const params = Astro.params;              // Dynamic route params
const request = Astro.request;            // Standard Request object
const headers = Astro.request.headers;    // Request headers
const cookies = Astro.cookies;            // Cookie helper
const clientAddress = Astro.clientAddress; // Client IP address
const locals = Astro.locals;             // Data from middleware
---

Cookies

---
// Reading cookies
const sessionId = Astro.cookies.get('session')?.value;

// Setting cookies
Astro.cookies.set('theme', 'dark', {
  path: '/',
  maxAge: 60 * 60 * 24 * 365, // 1 year
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
});

// Deleting cookies
Astro.cookies.delete('session');

// Checking existence
if (Astro.cookies.has('session')) {
  // handle authenticated user
}
---

Redirects and Status Codes

---
const user = await getUser(Astro.cookies.get('session')?.value);

if (!user) {
  return Astro.redirect('/login', 302);
}

// Return a 404
if (!user.hasAccess) {
  return new Response('Not found', { status: 404 });
}
---

<h1>Welcome, {user.name}</h1>

Implementation Patterns

Authentication Flow

---
// src/pages/dashboard.astro
const token = Astro.cookies.get('auth_token')?.value;

if (!token) {
  return Astro.redirect('/login');
}

let user;
try {
  const res = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error('Unauthorized');
  user = await res.json();
} catch {
  Astro.cookies.delete('auth_token');
  return Astro.redirect('/login');
}
---

<h1>Dashboard</h1>
<p>Hello, {user.name}</p>

Form Handling

---
// src/pages/contact.astro
let message = '';
let errors: Record<string, string> = {};

if (Astro.request.method === 'POST') {
  const formData = await Astro.request.formData();
  const name = formData.get('name')?.toString() ?? '';
  const email = formData.get('email')?.toString() ?? '';
  const body = formData.get('message')?.toString() ?? '';

  if (!name) errors.name = 'Name is required';
  if (!email.includes('@')) errors.email = 'Valid email required';
  if (!body) errors.message = 'Message is required';

  if (Object.keys(errors).length === 0) {
    await fetch('https://api.example.com/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, email, message: body }),
    });
    return Astro.redirect('/thank-you');
  }

  message = 'Please fix the errors below.';
}
---

<h1>Contact Us</h1>
{message && <p class="error">{message}</p>}

<form method="POST">
  <label>
    Name
    <input name="name" type="text" />
    {errors.name && <span class="error">{errors.name}</span>}
  </label>

  <label>
    Email
    <input name="email" type="email" />
    {errors.email && <span class="error">{errors.email}</span>}
  </label>

  <label>
    Message
    <textarea name="message"></textarea>
    {errors.message && <span class="error">{errors.message}</span>}
  </label>

  <button type="submit">Send</button>
</form>

Server-Side API Endpoints

// src/pages/api/users/[id].ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ params, request }) => {
  const user = await db.users.findById(params.id);

  if (!user) {
    return new Response(JSON.stringify({ error: 'Not found' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  return new Response(JSON.stringify(user), {
    headers: { 'Content-Type': 'application/json' },
  });
};

export const PUT: APIRoute = async ({ params, request }) => {
  const body = await request.json();
  const updated = await db.users.update(params.id, body);

  return new Response(JSON.stringify(updated), {
    headers: { 'Content-Type': 'application/json' },
  });
};

export const DELETE: APIRoute = async ({ params }) => {
  await db.users.delete(params.id);
  return new Response(null, { status: 204 });
};

Node Adapter with Express Middleware

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

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'middleware' }),
});
// server.mjs
import express from 'express';
import { handler as ssrHandler } from './dist/server/entry.mjs';

const app = express();

// Custom Express middleware
app.use('/api/legacy', legacyApiRouter);

// Astro SSR handler
app.use(ssrHandler);

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Response Streaming

Astro supports response streaming for faster time-to-first-byte:

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

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  // Streaming is enabled by default in Astro
});

Core Philosophy

Server-side rendering in Astro is an intentional escalation, not a default mode. Astro's design treats pre-rendered static pages as the gold standard for performance and reliability, and SSR exists specifically for the cases where request-time data is genuinely necessary: authentication, personalization, real-time content, and user-specific responses. This inversion makes every SSR page a conscious architectural decision rather than an unexamined default.

The hybrid rendering model reflects Astro's pragmatic approach to performance trade-offs. Within a single project, marketing pages and blog posts can be pre-rendered for maximum speed while dashboards and account pages are server-rendered for freshness. The prerender export makes this page-by-page decision explicit in the code, so anyone reading a route file immediately understands its rendering strategy without consulting a configuration file.

Astro's adapter system cleanly separates the rendering model from the deployment target. Your SSR code is written against the standard Web Fetch API (Request, Response, Headers, URL), and the adapter translates this to the platform's native format. This means changing hosting providers requires swapping one adapter, not rewriting route logic. The portability is real, but you must still respect each platform's constraints around available APIs, cold start characteristics, and execution time limits.

Anti-Patterns

  • Defaulting every page to server rendering when most content is static. If your site is 90% blog posts and marketing pages, setting output: 'server' globally wastes compute on pages that could be served from a CDN. Use hybrid mode and opt into SSR only where needed.

  • Accessing Astro.request or Astro.cookies in pre-rendered pages. Request-specific data does not exist at build time. Attempting to read headers, cookies, or client addresses in a static page causes errors or returns meaningless values.

  • Placing secrets in PUBLIC_-prefixed environment variables. Variables with the PUBLIC_ prefix are embedded in client-side JavaScript bundles. API keys and database credentials must use non-prefixed names and remain server-only.

  • Ignoring cold start latency on serverless platforms. Serverless adapters (Vercel, Netlify, Cloudflare) may incur cold start delays. Avoid heavy initialization at module scope and keep server-rendered pages lean to minimize time-to-first-byte.

  • Building complex form handling without using the Fetch API properly. Astro SSR pages handle forms through Astro.request.formData(), not through framework-specific form libraries. Fighting this pattern by importing React form libraries into SSR pages adds unnecessary complexity and bundle weight.

Best Practices

  • Use hybrid rendering: pre-render as many pages as possible and server-render only pages that truly need request-time data (auth, personalization, real-time data).
  • Add export const prerender = true to pages like marketing pages, docs, and blog posts that do not depend on request context.
  • Use Astro.cookies instead of manually parsing the Cookie header. The helper handles serialization and provides a cleaner API.
  • Return proper HTTP status codes. Use Astro.redirect() for redirects and new Response(null, { status: 404 }) for not-found pages.
  • Choose the standalone adapter mode for Docker deployments and middleware mode when integrating Astro into an existing Express or Fastify server.

Common Pitfalls

  • Forgetting to install an adapter: SSR mode without an adapter causes a build error. Every server-rendered Astro project needs an adapter for the target platform.
  • Using Astro.request in static pages: Request data like headers, cookies, and client address are only available in server-rendered pages. Accessing them in a pre-rendered page throws an error.
  • Environment variable exposure: In SSR, import.meta.env variables prefixed with PUBLIC_ are exposed to the client. Keep secrets in non-prefixed variables (e.g., API_SECRET, not PUBLIC_API_SECRET).
  • Cold start latency: Serverless adapters (Vercel, Netlify, Cloudflare) may have cold start delays. Keep SSR pages lightweight and avoid heavy initialization logic at module scope.
  • Adapter-specific limitations: Cloudflare Workers does not support Node.js built-in modules like fs or path. Vercel Edge Functions have size limits. Check adapter documentation for your platform's constraints.

Install this skill directly: skilldb add astro-skills

Get CLI access →