References

Server-Side Rendering (SSR) Patterns

Dynamic server rendering, Astro.locals, server endpoints, and authentication

Core Patterns

  • When to Read This
  • Project Detection
  • Astro.locals (Server Context)
  • Server Endpoints

When to Read This

  • Building dynamic pages with user-specific data
  • Implementing authentication and sessions
  • Using Astro.locals for server context
  • Creating server endpoints (POST/PUT/DELETE)
  • Adapter installed (node, vercel, netlify, etc.)

Project Detection

// astro.config.mjs
import node from "@astrojs/node";

export default defineConfig({
  output: "server", // SSR for all pages
  adapter: node({ mode: "standalone" }), // MUST have adapter
});

Without an adapter, SSR features will cause build errors.


Astro.locals (Server Context)

Setting Context in Middleware

// src/middleware.ts
import type { MiddlewareHandler } from "astro";

export const onRequest: MiddlewareHandler = async (context, next) => {
  const token = context.cookies.get("auth_token");

  if (token) {
    const user = await verifyToken(token.value);
    context.locals.user = user;
  }

  return next();
};

Accessing in Pages

---
// src/pages/dashboard.astro
export const prerender = false; // Enable SSR

const user = Astro.locals.user;

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

const data = await fetchUserData(user.id);
---

<div>
  <h1>Welcome, {user.name}</h1>
  <p>Your data: {JSON.stringify(data)}</p>
</div>

Server Endpoints

GET Request

// src/pages/api/user.ts
import type { APIRoute } from "astro";

export const GET: APIRoute = async ({ locals }) => {
  const user = locals.user;

  if (!user) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }

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

POST Request (Form Handling)

// src/pages/api/login.ts
export const POST: APIRoute = async ({ request, cookies }) => {
  const formData = await request.formData();
  const email = formData.get("email")?.toString();
  const password = formData.get("password")?.toString();

  if (!email || !password) {
    return new Response(JSON.stringify({ error: "Missing fields" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  const user = await authenticateUser(email, password);

  if (!user) {
    return new Response(JSON.stringify({ error: "Invalid credentials" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }

  const token = generateToken(user);
  cookies.set("auth_token", token, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });

  return new Response(JSON.stringify({ success: true }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

Dynamic Server Endpoints

// src/pages/api/posts/[id].ts
export const GET: APIRoute = async ({ params }) => {
  const post = await db.post.findUnique({ where: { id: params.id } });

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

  return new Response(JSON.stringify(post), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

export const PUT: APIRoute = async ({ params, request, locals }) => {
  if (!locals.user) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const data = await request.json();
  const post = await db.post.update({
    where: { id: params.id },
    data,
  });

  return new Response(JSON.stringify(post), { status: 200 });
};

export const DELETE: APIRoute = async ({ params, locals }) => {
  if (!locals.user) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  await db.post.delete({ where: { id: params.id } });

  return new Response(null, { status: 204 });
};

Authentication Patterns

Login Page

---
// src/pages/login.astro
const user = Astro.locals.user;

if (user) {
  return Astro.redirect('/dashboard');
}
---

<form method="POST" action="/api/login">
  <input type="email" name="email" required />
  <input type="password" name="password" required />
  <button type="submit">Login</button>
</form>

Protected Page

---
// src/pages/admin.astro
export const prerender = false;

const user = Astro.locals.user;

if (!user || user.role !== 'admin') {
  return Astro.redirect('/login');
}

const users = await db.user.findMany();
---

<div>
  <h1>Admin Dashboard</h1>
  <ul>
    {users.map(u => <li>{u.email}</li>)}
  </ul>
</div>

Logout Endpoint

// src/pages/api/logout.ts
export const POST: APIRoute = async ({ cookies }) => {
  cookies.delete("auth_token");

  return new Response(null, {
    status: 303,
    headers: { Location: "/" },
  });
};

Database Queries

---
// Runs on EVERY request (SSR)
export const prerender = false;

const posts = await db.post.findMany({
  where: { published: true },
  orderBy: { createdAt: 'desc' },
  take: 10,
});
---

<ul>
  {posts.map(post => (
    <li>
      <a href={`/blog/${post.slug}`}>{post.title}</a>
      <span>{new Date().toISOString()}</span>
    </li>
  ))}
</ul>

Caching Strategies

Response Caching

// src/pages/api/posts.ts
export const GET: APIRoute = async () => {
  const posts = await db.post.findMany();

  return new Response(JSON.stringify(posts), {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": "public, max-age=60", // Cache for 60 seconds
    },
  });
};

Stale-While-Revalidate

export const GET: APIRoute = async () => {
  const data = await fetchExpensiveData();

  return new Response(JSON.stringify(data), {
    headers: {
      "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
    },
  });
};

Environment Variables

---
// SSR: All env vars available (no PUBLIC_ prefix needed)
const dbUrl = import.meta.env.DATABASE_URL;
const apiSecret = import.meta.env.API_SECRET;
---

<script>
  // ❌ ERROR: Server-only vars NOT available client-side
  // const url = import.meta.env.DATABASE_URL;

  // ✅ CORRECT: Only PUBLIC_ vars available client-side
  const publicUrl = import.meta.env.PUBLIC_API_URL;
</script>

Error Handling

Custom Error Pages

---
// src/pages/404.astro
const url = Astro.url;
---

<div>
  <h1>404 - Page Not Found</h1>
  <p>The page <code>{url.pathname}</code> does not exist.</p>
</div>

Try-Catch in Endpoints

export const POST: APIRoute = async ({ request }) => {
  try {
    const data = await request.json();
    const result = await processData(data);
    return new Response(JSON.stringify(result), { status: 200 });
  } catch (error) {
    console.error("Error processing data:", error);
    return new Response(JSON.stringify({ error: "Internal server error" }), {
      status: 500,
    });
  }
};

Edge Cases

Request Object Only in SSR

---
export const prerender = false;

// ✅ Available in SSR
const method = Astro.request.method;
const headers = Astro.request.headers;
const body = await Astro.request.json();

// ❌ NOT available in SSG (build error)
---

Cookies Only in SSR

---
export const prerender = false;

// ✅ Available in SSR
const token = Astro.cookies.get('auth_token');
Astro.cookies.set('theme', 'dark', { maxAge: 86400 });

// ❌ NOT available in SSG
---

SEO Meta Tags and Sitemaps

Meta Tags with Astro’s <head>

---
// src/layouts/BaseLayout.astro
interface Props {
  title: string;
  description: string;
  image?: string;
  canonicalUrl?: string;
}

const {
  title,
  description,
  image = '/og/default.jpg',
  canonicalUrl = Astro.url.href,
} = Astro.props;
---

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <!-- Core SEO -->
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonicalUrl} />

    <!-- Open Graph -->
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={new URL(image, Astro.site)} />
    <meta property="og:url" content={canonicalUrl} />
    <meta property="og:type" content="website" />

    <!-- Twitter Card -->
    <meta name="twitter:card" content="summary_large_image" />
  </head>
  <body><slot /></body>
</html>

Dynamic Meta per Page

---
// src/pages/blog/[slug].astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({ params: { slug: post.slug }, props: { post } }));
}

const { post } = Astro.props;
---

<BaseLayout
  title={`${post.data.title} | My Blog`}
  description={post.data.description}
  image={post.data.heroImage}
/>

Generate Sitemap

# ✅ Official sitemap integration (SSG)
npx astro add sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com', // REQUIRED for sitemap
  integrations: [sitemap()],
});

Sitemap is generated at /sitemap-index.xml and /sitemap-0.xml at build time. For SSR, use the @astrojs/sitemap integration with serialize option for filtering or dynamic URLs.

robots.txt

# public/robots.txt — served as static file
User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml

References