Headless Apps
Craft Cloud uses advanced bot detection and makes a best effort to prioritize human traffic. This poses a challenge for headless apps: all content retrieval is automated and often arrives in concentrated bursts during static builds and background revalidation.
Two components are critical for a successful headless setup on Craft Cloud:
- Request signing:
- Use request signing from trusted server-side code to bypass the untrusted-bot policy.
- Signatures do not bypass shared capacity limits, so signed requests can
still receive
429or503responses. - Never expose the signing key to a browser or in a public environment variable.
- Automated retries:
- Treat every non-2xx response as a failure.
- For
429and503responses, honorRetry-Afterand use bounded retries with exponential backoff and jitter. - Only retry
POSTrequests that contain read-only GraphQL queries—never mutations. - Throw after retries are exhausted so
stale-while-revalidatecaching can preserve the last successful result.
#Automated Retries
A maintained Fetch client such as Ky (opens new window) can provide this retry policy. If you prefer not to add a dependency, use a small wrapper around the native Fetch API:
// Bound all attempts and delays.
const TOTAL_TIMEOUT = 30_000;
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
function getRetryDelay(response, attempt) {
const retryAfter = response.headers.get('Retry-After');
// Retry only responses that include Retry-After.
if (!retryAfter) {
return null;
}
const backoff = 1000 * 2 ** attempt * (0.5 + Math.random() / 2);
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return Math.max(backoff, seconds * 1000);
}
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) {
return Math.max(backoff, date - Date.now());
}
return backoff;
}
export async function fetchWithRetry(input, init = {}) {
const deadline = Date.now() + TOTAL_TIMEOUT;
for (let attempt = 0; ; attempt++) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new Error('Craft request timed out');
}
const timeoutSignal = AbortSignal.timeout(remaining);
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal;
const response = await fetch(input, { ...init, signal });
if (response.ok) {
return response;
}
const error = new Error(`Craft request failed: ${response.status}`);
const delay = getRetryDelay(response, attempt);
await response.body?.cancel();
if (delay === null) {
throw error;
}
if (Date.now() + delay >= deadline) {
throw error;
}
await sleep(delay);
}
}
#Request Signatures
Create a request-signatures.js module using getSignatureHeaders() from the
general Node.js signing example. The
framework examples below import that helper so signing does not interfere with
framework-specific request options.
#Next.js Example
Next.js can cache (opens new window) the validated result of a data-fetching function. This example uses Ky (opens new window) for the underlying request:
import ky from 'ky';
import { unstable_cache } from 'next/cache';
import { getSignatureHeaders } from './request-signatures.js';
const { CRAFT_URL, CRAFT_GRAPHQL_TOKEN } = process.env;
const method = 'POST';
const url = `${CRAFT_URL}/api`;
const query = `{ entries(section: "blog") { title url } }`;
const body = JSON.stringify({ query });
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CRAFT_GRAPHQL_TOKEN}`,
};
const getBlogEntries = unstable_cache(
async () => {
const signatureHeaders = getSignatureHeaders({ method, url, headers });
const result = await ky.post(url, {
body,
cache: 'no-store',
headers: {
...headers,
...signatureHeaders,
},
retry: {
limit: Number.POSITIVE_INFINITY,
methods: ['post'],
statusCodes: [429, 503],
jitter: true,
},
timeout: false,
totalTimeout: 30_000,
}).json();
if (result.errors?.length) {
throw new Error(result.errors.map((error) => error.message).join('\n'));
}
return result.data;
},
['craft:blog', url, query],
{
revalidate: 300,
tags: ['craft:blog'],
}
);
const data = await getBlogEntries();
Use narrow tags such as craft:blog or craft:products, and avoid bursts of
app-wide invalidations. When revalidation throws, Next.js continues serving
the last successful result and tries again on a later request.
Vercel provides stale-while-revalidate behavior through
ISR (opens new window). Netlify’s
current Next.js adapter (opens new window)
also supports the Full Route and Data caches, including tag- and path-based
revalidation.
#Nuxt Example
Nuxt’s $fetch uses ofetch (opens new window), which
can retry requests but does not provide this Retry-After and backoff policy.
Keep the signed request in a server route and use the shared helper:
// server/api/blog.get.js
import { fetchWithRetry } from '../utils/fetch-with-retry.js';
import { getSignatureHeaders } from '../utils/request-signatures.js';
const { CRAFT_URL, CRAFT_GRAPHQL_TOKEN } = process.env;
const method = 'POST';
const url = `${CRAFT_URL}/api`;
const query = `{ entries(section: "blog") { title url } }`;
const body = JSON.stringify({ query });
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CRAFT_GRAPHQL_TOKEN}`,
};
export default defineEventHandler(async () => {
const signatureHeaders = getSignatureHeaders({ method, url, headers });
const response = await fetchWithRetry(url, {
method,
body,
headers: {
...headers,
...signatureHeaders,
},
});
const result = await response.json();
if (result.errors?.length) {
throw new Error(result.errors.map((error) => error.message).join('\n'));
}
return result.data;
});
Apply stale-while-revalidate caching with a route rule, then call the route
from your components with useFetch('/api/blog'):
// nuxt.config.js
export default defineNuxtConfig({
routeRules: {
'/api/blog': { swr: 300 },
},
});
Nuxt also supports an isr route rule on Vercel and Netlify, but adapter
behavior differs. Netlify currently documents a
cache-control limitation for Nuxt ISR routes (opens new window),
so verify the deployed response before relying on CDN caching.
#Astro Example
Astro prerenders pages by default, so a failed Craft request should fail the build rather than publish partial content:
---
import { fetchWithRetry } from '../lib/fetch-with-retry.js';
import { getSignatureHeaders } from '../lib/request-signatures.js';
const { CRAFT_URL, CRAFT_GRAPHQL_TOKEN } = process.env;
const method = 'POST';
const url = `${CRAFT_URL}/api`;
const query = `{ entries(section: "blog") { title url } }`;
const body = JSON.stringify({ query });
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CRAFT_GRAPHQL_TOKEN}`,
};
const signatureHeaders = getSignatureHeaders({ method, url, headers });
const response = await fetchWithRetry(url, {
method,
body,
headers: {
...headers,
...signatureHeaders,
},
});
const result = await response.json();
if (result.errors?.length) {
throw new Error(result.errors.map((error) => error.message).join('\n'));
}
const data = result.data;
---
Netlify uses atomic deploys (opens new window), and Vercel promotes successful deployments to production. A failed build therefore leaves the current production app in place.
For on-demand rendering, Astro 7 provides a
route cache API (opens new window) with
stale-while-revalidate semantics.