Mocking headless CMS APIs in CI/CD pipelines
Mocking the CMS API in CI replaces a flaky live dependency — rate-limited gateways, multi-region propagation lag, async draft state — with frozen fixtures that mirror the production schema. The payoff is deterministic, millisecond-fast builds with no 429s and no webhook race conditions across parallel jobs.
The Determinism Gap in CI/CD
Contentful, Sanity, Strapi, and Prismic are eventually consistent. Multi-region propagation, draft/published segregation, and quota enforcement all clash with the stateless, synchronous CI runner. Hitting live endpoints produces three compounding failures:
- Ephemeral IPs and rate limiting. GitHub Actions, GitLab CI, and Vercel/Netlify nodes share rotating IP ranges. CMS limiters read concurrent jobs as distributed scraping and return
429 Too Many Requestsor403 Forbidden. - State drift during builds. Webhook-triggered deploys often fire before content propagates to edge caches, so
next buildorvite buildfetches stale or null payloads — hydration mismatches, broken SSG, ISR cache poisoning. - Non-deterministic payloads. Draft fields, locale fallback chains, and reference resolution differ between preview and production. An editor changing a value mid-pipeline produces flaky assertion failures.
The fix: intercept at the runtime boundary, freeze deterministic payloads, and enforce contract validation.
Step-by-Step Resolution
1. Transport-Layer Interception
Intercept fetch/axios with Mock Service Worker (MSW) or nock. MSW hooks the undici interceptor in Node, capturing outbound requests before they hit the network — zero external traffic, no DNS overhead, millisecond responses regardless of CMS uptime.
2. Schema Extraction and Fixture Freezing
Query the preview endpoint once in a controlled run, strip volatile metadata (sys.updatedAt, timestamps, draft flags, cache-control headers), and commit the result as versioned JSON fixtures next to the test suite. The baseline survives content updates while preserving exact field shapes.
3. Strict Contract Validation
Validate frozen fixtures against TypeScript interfaces or Zod schemas derived from your content models. Diverge from the expected types and the pipeline fails — schema drift is caught before a broken component reaches staging.
4. Environment-Driven Routing
Set CI_MOCK_CMS=true and route fetches through a conditional interceptor factory that only activates outside production. Local dev and production keep live API behavior; CI gets mocks.
5. Context Isolation for Builds and Tests
Load mocks only during test execution and static generation, never during runtime SSR/CSR, or fixtures leak into the production bundle. Build-time resolution and runtime hydration stay strictly separated, as in the rest of your Data Fetching & Caching Strategies.
Production Implementation
MSW Node.js Interceptor Setup
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { z } from 'zod';
// Zod schema matching CMS content model
export const ArticleSchema = z.object({
sys: z.object({ id: z.string(), type: z.literal('Entry') }),
fields: z.object({
title: z.string(),
slug: z.string(),
body: z.string(),
publishDate: z.string().datetime(),
}),
});
// Load frozen fixtures
import articleFixture from './fixtures/article-v1.json';
export const handlers = [
http.get('https://cdn.contentful.com/spaces/:spaceId/environments/:envId/entries', async ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('content_type');
if (query === 'article') {
const validated = ArticleSchema.safeParse(articleFixture);
if (!validated.success) {
return HttpResponse.json({ error: 'Fixture schema mismatch' }, { status: 500 });
}
return HttpResponse.json({ items: [validated.data] }, { status: 200 });
}
return HttpResponse.json({ items: [] }, { status: 200 });
}),
];
export const server = setupServer(...handlers);
Conditional Fetch Wrapper
// src/lib/cms-client.ts
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';
const isCI = process.env.CI === 'true';
const mockEnabled = process.env.CI_MOCK_CMS === 'true';
let serverInstance: ReturnType<typeof setupServer> | null = null;
if (isCI && mockEnabled) {
serverInstance = setupServer(...handlers);
serverInstance.listen({ onUnhandledRequest: 'error' });
}
export const fetchCMS = async <T>(endpoint: string, options?: RequestInit): Promise<T> => {
const res = await fetch(endpoint, options);
if (!res.ok) throw new Error(`CMS request failed: ${res.status}`);
return res.json() as Promise<T>;
};
export const teardownMocks = () => serverInstance?.close();
Pipeline Configuration (GitHub Actions)
name: CI Pipeline
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
env:
CI: true
CI_MOCK_CMS: true
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test:ci
- run: npm run build
Pipeline Integration & Verification
Add a pre-build schema check so any CMS model change is reflected in the frozen fixtures before deployment.
// scripts/validate-fixtures.ts
import { ArticleSchema } from '../src/mocks/handlers';
import fixture from '../src/mocks/fixtures/article-v1.json';
try {
ArticleSchema.parse(fixture);
console.log('✅ Fixture contract valid');
process.exit(0);
} catch (err) {
console.error('❌ Fixture contract violation:', err);
process.exit(1);
}
Run this before the test suite. As part of Automated Testing for Headless Integrations, it catches type mismatches, missing locale fallbacks, and broken reference chains at the pull-request stage instead of in production.
Edge Cases and Mitigation
| Failure Vector | Mitigation Strategy |
|---|---|
| Locale Fallback Mismatch | Include explicit locale keys in fixtures (en-US, de-DE). Validate fallback chains using Zod .refine() or custom assertion functions. |
| Reference Resolution Cycles | Flatten nested references in fixtures. Use __ref ID patterns instead of deeply nested objects to prevent circular serialization during build steps. |
| Rate Limit Bypass During Local Dev | Scope MSW activation strictly to process.env.CI_MOCK_CMS === 'true'. Local developers should hit staging APIs to catch real-world latency and caching behavior. |
| ISR/SSG Cache Poisoning | Ensure mock responses include Cache-Control: no-store headers during CI builds. This prevents Next.js/Vite from persisting mock payloads into .next/cache or dist artifacts. |
Intercept at the transport layer, freeze validated fixtures, and gate mock activation behind an environment toggle: the build goes from flaky and network-dependent to deterministic and fast, scaling across parallel jobs while staying in exact parity with production content models.