Astro SSR vs SSG Testing Patterns
Astro's hybrid rendering model is one of its most powerful features — you can mix static site generation (SSG) and server-side rendering (SSR) in the same project, choosing per page. A blog index might be static, a user dashboard SSR, and a product page somewhere in between with on-demand revalidation. Each rendering mode has fundamentally different testing requirements.
This guide explains how to test each mode correctly, including getStaticPaths, dynamic routes, SSR with cookies and sessions, and the specific challenges of build-time vs runtime testing.
Understanding the Rendering Modes
Before writing tests, get clear on what each mode means at runtime:
SSG (Static Site Generation): Pages are pre-rendered at build time. The output is static HTML files. There's no server involved for serving these pages — a CDN delivers them directly. Testing happens at build time (verify the build produces correct output) or in the browser (verify the HTML works correctly).
SSR (Server-Side Rendering): Pages render on every request. A Node.js server (or edge runtime) processes each request and generates HTML dynamically. Testing requires a running server.
Hybrid: Astro's default since v2. Pages are SSG by default. Add export const prerender = false to opt individual pages into SSR. Or set output: 'server' in astro.config.mjs for SSR-first with export const prerender = true for static pages.
Testing SSG Pages and getStaticPaths
getStaticPaths is the SSG function that tells Astro which dynamic URL parameters to pre-render. It's pure data transformation — it takes nothing and returns an array of params/props. Test it as a regular function.
---
// src/pages/blog/[slug].astro
import { getCollection, type CollectionEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
<Content />
</article>Test getStaticPaths with mocked content collections:
// src/pages/blog/__tests__/slug.staticpaths.test.ts
import { describe, expect, test, vi, beforeEach } from 'vitest';
vi.mock('astro:content', () => ({
getCollection: vi.fn(),
}));
import { getCollection } from 'astro:content';
// Import only the getStaticPaths export
import { getStaticPaths } from '../[slug].astro';
const mockPosts = [
{
id: 'post-1',
slug: 'my-first-post',
collection: 'blog',
data: {
title: 'My First Post',
pubDate: new Date('2024-01-15'),
draft: false,
tags: ['astro'],
},
render: vi.fn(),
body: '# Hello world',
},
{
id: 'post-2',
slug: 'a-draft-post',
collection: 'blog',
data: {
title: 'Draft Post',
pubDate: new Date('2024-02-01'),
draft: true,
tags: ['draft'],
},
render: vi.fn(),
body: '# Not yet published',
},
{
id: 'post-3',
slug: 'second-published-post',
collection: 'blog',
data: {
title: 'Second Published Post',
pubDate: new Date('2024-03-01'),
draft: false,
tags: ['astro', 'testing'],
},
render: vi.fn(),
body: '# Published!',
},
];
describe('Blog [slug] getStaticPaths', () => {
beforeEach(() => {
// The filter in getStaticPaths removes drafts — simulate what getCollection does
vi.mocked(getCollection).mockImplementation(async (collection, filter) => {
if (filter) {
return mockPosts.filter(post => filter(post as any)) as any;
}
return mockPosts as any;
});
});
test('returns path for each published post', async () => {
const paths = await getStaticPaths();
expect(paths).toHaveLength(2); // Only non-draft posts
expect(paths.map(p => p.params.slug)).toContain('my-first-post');
expect(paths.map(p => p.params.slug)).toContain('second-published-post');
});
test('excludes draft posts from generated paths', async () => {
const paths = await getStaticPaths();
const slugs = paths.map(p => p.params.slug);
expect(slugs).not.toContain('a-draft-post');
});
test('includes post data in props', async () => {
const paths = await getStaticPaths();
const firstPath = paths.find(p => p.params.slug === 'my-first-post');
expect(firstPath?.props.post.data.title).toBe('My First Post');
});
test('each path has a slug param', async () => {
const paths = await getStaticPaths();
paths.forEach(path => {
expect(path.params).toHaveProperty('slug');
expect(typeof path.params.slug).toBe('string');
expect(path.params.slug.length).toBeGreaterThan(0);
});
});
});Testing Dynamic SSG Routes with Parameters
Some SSG pages have multiple dynamic segments. Test that getStaticPaths generates the correct Cartesian product:
// src/pages/docs/[version]/[section].astro — generates paths like /docs/v1/getting-started
export async function getStaticPaths() {
const versions = ['v1', 'v2', 'v3'];
const sections = ['getting-started', 'api-reference', 'examples'];
return versions.flatMap(version =>
sections.map(section => ({
params: { version, section },
props: { version, section },
}))
);
}// src/pages/docs/__tests__/version-section.test.ts
import { describe, expect, test } from 'vitest';
import { getStaticPaths } from '../[version]/[section].astro';
describe('Docs [version]/[section] getStaticPaths', () => {
test('generates all version/section combinations', async () => {
const paths = await getStaticPaths();
// 3 versions × 3 sections = 9 paths
expect(paths).toHaveLength(9);
});
test('includes all versions', async () => {
const paths = await getStaticPaths();
const versions = [...new Set(paths.map(p => p.params.version))];
expect(versions).toContain('v1');
expect(versions).toContain('v2');
expect(versions).toContain('v3');
});
test('each path has both required params', async () => {
const paths = await getStaticPaths();
paths.forEach(path => {
expect(path.params).toHaveProperty('version');
expect(path.params).toHaveProperty('section');
});
});
test('generates specific expected path', async () => {
const paths = await getStaticPaths();
const target = paths.find(
p => p.params.version === 'v2' && p.params.section === 'api-reference'
);
expect(target).toBeDefined();
});
});Testing Build Output for SSG
For SSG, the build process itself is a test artifact. You can verify the build produces the expected files:
// src/__tests__/build-output.test.ts
import { describe, expect, test, beforeAll } from 'vitest';
import { execSync } from 'child_process';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { join } from 'path';
const DIST_DIR = join(process.cwd(), 'dist');
describe('SSG build output', () => {
beforeAll(() => {
// Only build if dist doesn't exist (avoid rebuilding in watch mode)
if (!existsSync(DIST_DIR)) {
execSync('npm run build', { stdio: 'pipe' });
}
}, 120000);
test('generates index.html', () => {
expect(existsSync(join(DIST_DIR, 'index.html'))).toBe(true);
});
test('generates blog post pages', () => {
const blogDir = join(DIST_DIR, 'blog');
expect(existsSync(blogDir)).toBe(true);
const entries = readdirSync(blogDir, { withFileTypes: true });
const directories = entries.filter(e => e.isDirectory());
expect(directories.length).toBeGreaterThan(0);
});
test('each blog post directory contains index.html', () => {
const blogDir = join(DIST_DIR, 'blog');
const entries = readdirSync(blogDir, { withFileTypes: true });
const directories = entries.filter(e => e.isDirectory());
directories.forEach(dir => {
const indexPath = join(blogDir, dir.name, 'index.html');
expect(existsSync(indexPath)).toBe(true);
});
});
test('generated HTML includes required meta tags', () => {
const indexHtml = readFileSync(join(DIST_DIR, 'index.html'), 'utf-8');
expect(indexHtml).toContain('<meta name="description"');
expect(indexHtml).toContain('<meta property="og:title"');
expect(indexHtml).toContain('<title>');
});
test('generated HTML does not contain development artifacts', () => {
const indexHtml = readFileSync(join(DIST_DIR, 'index.html'), 'utf-8');
expect(indexHtml).not.toContain('localhost:');
expect(indexHtml).not.toContain('__ASTRO_DEV__');
});
test('sitemap.xml is generated', () => {
expect(existsSync(join(DIST_DIR, 'sitemap-index.xml'))).toBe(true);
});
});Testing SSR Pages with Cookies and Sessions
SSR pages can read cookies, set cookies, and maintain session state. Testing this requires actually setting cookies on requests.
---
// src/pages/dashboard.astro
export const prerender = false;
import { validateSession } from '../lib/session';
const sessionCookie = Astro.cookies.get('session');
if (!sessionCookie?.value) {
return Astro.redirect('/login?next=/dashboard');
}
const session = await validateSession(sessionCookie.value);
if (!session) {
// Invalid session — clear cookie and redirect
Astro.cookies.delete('session', { path: '/' });
return Astro.redirect('/login?next=/dashboard');
}
const { user } = session;
---
<html>
<body>
<h1>Welcome, {user.name}</h1>
<p>Last login: {new Date(user.lastLogin).toLocaleString()}</p>
<nav>
<a href="/dashboard/settings">Settings</a>
<a href="/dashboard/billing">Billing</a>
</nav>
</body>
</html>Playwright tests for the SSR dashboard with cookies:
// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Dashboard (SSR with sessions)', () => {
test('redirects to login when no session cookie', async ({ page }) => {
// Start with no cookies
await page.context().clearCookies();
const response = await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?next=%2Fdashboard/);
});
test('renders dashboard with valid session', async ({ page, context }) => {
// Set a valid session cookie
await context.addCookies([{
name: 'session',
value: 'valid-session-token-for-test-user',
domain: 'localhost',
path: '/',
httpOnly: true,
secure: false,
}]);
await page.goto('/dashboard');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('clears cookie and redirects for invalid session', async ({ page, context }) => {
// Set an expired/invalid session cookie
await context.addCookies([{
name: 'session',
value: 'expired-or-invalid-session',
domain: 'localhost',
path: '/',
httpOnly: true,
}]);
await page.goto('/dashboard');
// Should redirect to login
await expect(page).toHaveURL(/\/login/);
// Session cookie should be cleared
const cookies = await context.cookies();
const sessionCookie = cookies.find(c => c.name === 'session');
expect(sessionCookie).toBeUndefined();
});
test('session is scoped per user', async ({ browser }) => {
// Create two independent browser contexts
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
await ctx1.addCookies([{
name: 'session',
value: 'user-1-session-token',
domain: 'localhost',
path: '/',
}]);
await ctx2.addCookies([{
name: 'session',
value: 'user-2-session-token',
domain: 'localhost',
path: '/',
}]);
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
await page1.goto('/dashboard');
await page2.goto('/dashboard');
const name1 = await page1.locator('h1').textContent();
const name2 = await page2.locator('h1').textContent();
expect(name1).not.toBe(name2);
await ctx1.close();
await ctx2.close();
});
});Testing SSR Query Parameters
SSR pages often use query parameters for filtering, pagination, and search. Test that Astro.url.searchParams is handled correctly:
---
// src/pages/search.astro
export const prerender = false;
const query = Astro.url.searchParams.get('q') ?? '';
const page = Math.max(1, parseInt(Astro.url.searchParams.get('page') ?? '1'));
const perPage = 10;
if (!query) {
// Empty search — show prompt
const results: any[] = [];
const totalPages = 0;
return;
}
const { results, total } = await searchContent(query, { page, perPage });
const totalPages = Math.ceil(total / perPage);
---// e2e/search.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Search page (SSR with query params)', () => {
test('renders search prompt with no query', async ({ page }) => {
await page.goto('/search');
await expect(page.locator('.search-prompt')).toBeVisible();
await expect(page.locator('.search-results')).not.toBeVisible();
});
test('renders results for valid query', async ({ page }) => {
await page.goto('/search?q=astro');
await expect(page.locator('.search-results')).toBeVisible();
await expect(page.locator('.search-result')).toHaveCount({ minimum: 1 });
});
test('handles pagination via page parameter', async ({ page }) => {
await page.goto('/search?q=javascript&page=2');
const results = page.locator('.search-result');
await expect(results).toHaveCount({ minimum: 1 });
// Page 2 indicator should be active
await expect(page.locator('[aria-current="page"]')).toContainText('2');
});
test('invalid page parameter defaults to page 1', async ({ page }) => {
await page.goto('/search?q=astro&page=invalid');
// Should still render results, defaulting to page 1
await expect(page.locator('.search-results')).toBeVisible();
await expect(page.locator('[aria-current="page"]')).toContainText('1');
});
test('encodes special characters in query correctly', async ({ page }) => {
await page.goto('/search?q=hello+world');
// The page should handle URL-encoded spaces
await expect(page.locator('.search-query-display')).toContainText('hello world');
});
test('XSS in query parameter is escaped', async ({ page }) => {
await page.goto('/search?q=<script>alert(1)</script>');
// The page should render without executing the script
const alerts: string[] = [];
page.on('dialog', dialog => {
alerts.push(dialog.message());
dialog.dismiss();
});
await page.waitForLoadState('networkidle');
expect(alerts).toHaveLength(0);
// The raw script tag should not appear in the DOM as executable
const scriptElements = await page.locator('script:not([src])').count();
// Only legitimate inline scripts (like Astro's hydration scripts)
// The injected script should be escaped in text content
const bodyContent = await page.locator('body').innerHTML();
expect(bodyContent).not.toContain('<script>alert(1)</script>');
});
});Build-time vs Runtime Testing Strategy
The core principle: SSG pages need build-time tests; SSR pages need runtime tests.
┌─────────────────────────────────────────────────────┐
│ Testing Matrix │
├────────────────┬────────────────┬───────────────────┤
│ │ SSG Pages │ SSR Pages │
├────────────────┼────────────────┼───────────────────┤
│ Unit tests │ getStaticPaths │ API handler logic │
│ │ data transform │ auth logic │
│ │ content filter │ session validation │
├────────────────┼────────────────┼───────────────────┤
│ Build tests │ HTML output │ N/A (not static) │
│ │ file existence │ │
│ │ asset hashes │ │
├────────────────┼────────────────┼───────────────────┤
│ Runtime tests │ Browser behav │ HTTP responses │
│ │ hydration │ cookies/sessions │
│ │ navigation │ query params │
│ │ │ redirects │
└────────────────┴────────────────┴───────────────────┘Organize your test commands to match:
{
"scripts": {
"test:unit": "vitest run src --exclude '**/*.e2e.ts'",
"test:build": "npm run build && vitest run src/__tests__/build-output.test.ts",
"test:e2e:dev": "playwright test --config=playwright.config.ts",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:all": "npm run test:unit && npm run test:build && npm run test:e2e:prod"
}
}Testing Hybrid Projects (Mixed SSG and SSR)
When your project mixes both modes, your test suite needs to handle both. A good approach is to tag your E2E tests by rendering mode:
// e2e/blog-post.spec.ts — SSG page test
test.describe('Blog post (SSG) @ssg', () => {
test('loads content without server', async ({ page }) => {
await page.goto('/blog/my-first-post');
await expect(page.locator('article')).toBeVisible();
});
test('has correct canonical URL in meta', async ({ page }) => {
await page.goto('/blog/my-first-post');
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
expect(canonical).toContain('/blog/my-first-post');
});
});
// e2e/user-profile.spec.ts — SSR page test
test.describe('User profile (SSR) @ssr', () => {
test('reads fresh data on each request', async ({ page, context }) => {
await context.addCookies([/* auth cookie */]);
await page.goto('/profile');
const firstLoad = await page.locator('.last-updated').textContent();
await page.reload();
const secondLoad = await page.locator('.last-updated').textContent();
// SSR pages re-render — timestamps will differ if they include seconds
// This validates that data is not cached from build time
expect(firstLoad).toBeTruthy();
});
});Run mode-specific tests:
# Only SSG tests
npx playwright test --grep "@ssg"
# Only SSR tests
npx playwright test --grep "@ssr"Common Pitfalls
Testing SSG pages against the dev server: The dev server renders SSG pages on-demand, mimicking SSR behavior. This masks bugs that only appear in the static build. Always validate your SSG pages against the production preview (npm run preview).
Forgetting prerender = false: In an SSR project (output: 'server'), forgetting this export on a page that needs to be static silently makes it SSR. Add a test that checks the rendered output has no server-side indicators for pages that should be static.
Cookie security in tests: When testing with httpOnly: true cookies, you can't read them from JavaScript. Set them through Playwright's context.addCookies() — this bypasses the HttpOnly restriction in the test context.
Build caching breaking tests: The beforeAll that runs npm run build can produce stale output if tests pass on an old build. Use --force-rebuild flags or check the build timestamp in your test setup.
The discipline is simple: know your rendering mode, match your test type to it, and never assume a passing unit test means a passing page. SSG needs build validation. SSR needs a real server. Test both.