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.

Follow these guidelines for a successful headless setup on Craft Cloud:

  • Request signing: Sign requests within your hosting platform, such as Vercel or Netlify, to bypass the stricter untrusted-bot policy. Never expose the signing key to browser code or a public environment variable.
  • Automated retries: Retries provide resilience against unavoidable transient network errors, not just rate limits. Rate limits exist to protect your origin. Without them, traffic bursts could overwhelm your database and result in more problematic errors.
    • Automated builds can issue many requests in a short window. If possible, slow the request rate by reducing build concurrency or adding an interval between requests. Nuxt’s Nitro engine (opens new window) (no relation (opens new window)) supports both options.
    • When possible, send GraphQL queries with GET requests so successful responses can be cached by your hosting platform.
    • For retryable error responses, honor Retry-After, ideally with exponential backoff.
    • Only retry POST requests that contain read-only GraphQL queries—never mutations.

#Automated Retries

Resilient automated requests should handle network errors, Retry-After, exponential backoff, and jitter. For brevity, the examples below use Ky (opens new window) for this policy, but no dependency is required.

#Request Signatures

Create a request-signatures.js module using getSignatureHeaders() from the general Node.js signing example. The framework examples below construct and sign a native Request before sending it.

#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 request = new Request(url, { method, body, headers });

    for (const [name, value] of Object.entries(getSignatureHeaders(request))) {
      request.headers.set(name, value);
    }

    const result = await ky(request, {
      cache: 'no-store',
      retry: {
        limit: 10,
        methods: ['get', 'post'],
        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

Keep the signed request in a Nuxt server route:

// server/api/blog.get.js
import ky from 'ky';
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 request = new Request(url, { method, body, headers });

  for (const [name, value] of Object.entries(getSignatureHeaders(request))) {
    request.headers.set(name, value);
  }

  const result = await ky(request, {
    retry: {
      limit: 10,
      methods: ['get', 'post'],
      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;
});

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 ky from 'ky';
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 request = new Request(url, { method, body, headers });

for (const [name, value] of Object.entries(getSignatureHeaders(request))) {
  request.headers.set(name, value);
}

const result = await ky(request, {
  retry: {
    limit: 10,
    methods: ['get', 'post'],
    jitter: true,
  },
  timeout: false,
  totalTimeout: 30_000,
}).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.