Headless CMS Rate Limiting and Caching: What Developers Get Wrong
5 mistakes, with code fixes for ISR, webhooks, and Cache-Control.
Your headless CMS API returns content in 40ms during development. In production, with real traffic and a team of engineers hitting the same endpoint, you start seeing 429 Too Many Requests and pages that serve stale data from three days ago. Not because the CMS is broken — because the caching and rate-limiting strategy was never designed for production load.
This post covers the five most common mistakes developers make when consuming a headless CMS API, with concrete fixes for each. If you're building on UnfoldCMS's REST API or any standard JSON headless CMS, these patterns apply directly.
TL;DR: Headless CMS APIs are fast and stateless by nature, but that doesn't mean you can hit them raw on every request. The winning pattern is: cache aggressively at every layer (CDN → server → client), invalidate precisely via webhooks on publish, and keep your rate-limit budget for real user actions — not build-time fetches or dev-team traffic.
The 5 Caching Layers for a Headless CMS Stack
Before we get to mistakes, here's the map. Every production headless stack has (or should have) five places where responses can be cached:
| Layer | What it caches | Who controls it |
|---|---|---|
| CDN (Cloudflare, Fastly, CloudFront) | Full HTTP responses from your frontend | You, via Cache-Control headers |
| Server / SSG cache | Rendered HTML pages | Your framework (Next.js, Astro, etc.) |
| CMS API response | Raw JSON from /api/v1/posts etc. |
Your fetch layer + CMS config |
| Client-side cache | In-memory or localStorage | Your app code (React Query, SWR) |
| Browser cache | Static assets + API responses with proper headers | Browser, guided by headers |
Most teams get layers 4 and 5 right by accident (browsers just cache things). The problems happen at layers 1–3.
Rate Limiting: What It Is and Why You Will Hit It
A rate limit caps how many API requests a client can make within a time window. UnfoldCMS applies a 60 requests/minute throttle on the api middleware group. That sounds like a lot until you count what actually hits the endpoint.
What 60 req/min looks like under real conditions:
- A Next.js build fetching 500 posts: that's 50 paginated requests (10 posts/page) in one
next buildrun - 5 developers running
next devlocally: each page load firesgetServerSideProps→ each fires an API call - A Vercel preview deployment + a production deployment running in parallel: double the build traffic
- An ISR revalidation cycle where 20 popular posts refresh in the same minute: 20 requests from your own server
At 60 req/min, a team of 5 doing active development can saturate the budget within seconds. The CMS starts returning 429, your builds fail, and the developer experience falls apart.
The fix isn't "raise the limit" — it's to stop treating the CMS API like a live database that needs polling.
The 5 Mistakes (and How to Fix Each)
Mistake 1: Every getStaticProps / getServerSideProps Re-Fetches Live
This is the most common one. A Next.js page that does this:
// pages/blog/[slug].js
export async function getServerSideProps({ params }) {
const res = await fetch(`https://your-cms.com/api/v1/posts/${params.slug}`);
const post = await res.json();
return { props: { post } };
}
...fires a fresh API request on every single page view. With 500 daily visitors hitting a blog post, that's 500 API calls per day for one page. Scale to 50 posts and you're burning 25,000 requests daily.
The fix: use getStaticProps with ISR for content pages.
export async function getStaticProps({ params }) {
const res = await fetch(`https://your-cms.com/api/v1/posts/${params.slug}`);
const post = await res.json();
return {
props: { post },
revalidate: 300, // Re-fetch at most every 5 minutes
};
}
export async function getStaticPaths() {
const res = await fetch('https://your-cms.com/api/v1/posts?per_page=100');
const { data } = await res.json();
return {
paths: data.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking',
};
}
Now each page fetches from the CMS API once at build time, then at most once every 5 minutes when someone visits — not on every request. Your 500 daily visitors consume 1 API call instead of 500 for that page.
See how headless CMS architecture changes the caching model versus a traditional CMS — the decoupled nature means you control exactly this layer.
Mistake 2: All 5 Developers Sharing One IP Exhaust the Throttle
The rate limit is per IP. During development, everyone on your team is likely running next dev against production CMS credentials. If your office has a shared NAT or your team is on the same VPN exit node, all 5 developers share a single IP address — and a single 60 req/min bucket.
One developer refreshing a page every 10 seconds while another runs a build at the same time = burst exceeded.
The fix: run a local CMS instance for development.
UnfoldCMS self-hosts on any $4/month VPS. Set up a dev instance (even SQLite is fine for local), seed it with sample data, and point NEXT_PUBLIC_CMS_URL to localhost during development. Production credentials stay in production.
If a local instance isn't practical, cache API responses during dev:
// lib/cms.js
const cache = new Map();
export async function getCmsPost(slug) {
if (process.env.NODE_ENV === 'development' && cache.has(slug)) {
return cache.get(slug);
}
const res = await fetch(`${process.env.CMS_URL}/api/v1/posts/${slug}`);
const data = await res.json();
if (process.env.NODE_ENV === 'development') cache.set(slug, data);
return data;
}
This in-memory cache resets on server restart but survives hot-reloads — cutting dev API calls by 80–90%.
Mistake 3: Not Setting Cache-Control Headers on CMS Responses
UnfoldCMS returns standard JSON with no Cache-Control header by default (since the CMS itself is self-hosted and you control the web server). That means your CDN — Cloudflare, Fastly, whatever sits in front of your frontend — sees no caching directive and either passes every request through or applies its own defaults.
The problem: CDN defaults vary. Cloudflare's default for non-asset responses is often no caching at all, meaning every user request travels from CDN → your server → CMS API. You've added two hops without gaining any caching benefit.
The fix: add Cache-Control headers at the edge.
If you're using Next.js, add headers to API route responses:
// app/api/posts/[slug]/route.js
export async function GET(request, { params }) {
const res = await fetch(`${process.env.CMS_URL}/api/v1/posts/${params.slug}`);
const post = await res.json();
return Response.json(post, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=3600',
},
});
}
s-maxage=300 tells CDNs (not browsers) to cache for 5 minutes. stale-while-revalidate=3600 lets the CDN serve the stale version for up to an hour while it revalidates in the background — zero user-visible latency.
For Astro, configure response headers in astro.config.mjs or your adapter settings.
This single header can reduce your CMS API calls from 10,000/day to 50/day on a moderately trafficked blog.
Mistake 4: No Cache Invalidation on Publish
If you cache aggressively (good!) but don't invalidate on publish (bad), your readers will see stale content for hours after you hit "Publish" in the CMS admin. This is the trade-off that kills teams' confidence in caching — they get burned by stale content once, then turn off caching entirely, and burn their rate limit instead.
The right answer is precise invalidation via webhooks.
UnfoldCMS fires HMAC-signed webhooks on content publish events. The webhook payload includes the post slug, making it trivial to invalidate exactly the page that changed — not the whole cache.
The pattern for Next.js on-demand ISR:
// pages/api/revalidate.js
import { createHmac } from 'crypto';
export default async function handler(req, res) {
// Verify HMAC signature from UnfoldCMS webhook
const signature = req.headers['x-unfoldcms-signature'];
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== `sha256=${expected}`) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { slug, type } = req.body;
if (type === 'post.published' || type === 'post.updated') {
await res.revalidate(`/blog/${slug}`);
await res.revalidate('/blog'); // Also bust the index
}
return res.json({ revalidated: true });
}
Configure this URL in your CMS webhook settings. Now when you publish a post, the webhook fires within seconds, Next.js regenerates exactly those two pages, and your CDN cache is warm with fresh content before the first reader clicks the link.
See the full webhook setup in CMS Webhooks: Trigger Front-End Rebuilds on Publish.
Mistake 5: Over-Fetching — Pulling 500 Posts When You Need 10
A classic. You need to render a "Latest Posts" sidebar with 5 posts. The naive fetch:
const res = await fetch('https://your-cms.com/api/v1/posts');
const { data: posts } = await res.json();
const latest = posts.slice(0, 5); // Take first 5
If your CMS has 500 posts and the default per_page is 100, you're fetching 100 full post objects (with body content, metadata, all fields) to display 5 titles and thumbnails.
The fix: use query parameters to fetch exactly what you need.
UnfoldCMS's public endpoints support per_page and pagination. For a sidebar:
const res = await fetch(
'https://your-cms.com/api/v1/posts?per_page=5&page=1'
);
That's one request, 5 items, no waste. If you need posts by category:
const res = await fetch(
'https://your-cms.com/api/v1/posts?per_page=10&category=tutorials'
);
For the full API reference, see the docs. The public read endpoints — /api/v1/posts, /api/v1/pages, /api/v1/categories — support pagination and filtering without authentication.
This applies at build time too. If you're using getStaticPaths to pre-render every blog post, fetch slugs only (not full post bodies). Slug arrays are tiny; full post objects are 5–50KB each.
Cache Invalidation via Webhooks: The Right Approach
Let's go deeper on webhooks, because this is where most developer understanding breaks down.
A webhook from UnfoldCMS is an HTTP POST with an HMAC-SHA256 signature in the X-UnfoldCMS-Signature header. The payload tells you what changed — the post ID, slug, status, and event type.
Your webhook handler should do three things:
- Verify the signature. Unsigned webhooks are a security risk — anyone could POST fake publish events to your revalidation endpoint. The HMAC check above takes 5 lines.
- Bust the specific URLs. Don't nuke the whole CDN cache. Invalidate
/blog/post-slugand/blog(the index). For taxonomy pages, also invalidate/blog/category/category-name. - Return fast. The CMS waits for your webhook endpoint to respond. If it times out, it retries. Your handler should queue background work and return
200within 500ms.
For Cloudflare, you can call the Cache Purge API directly from your webhook handler:
await fetch(
`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [
`https://yoursite.com/blog/${slug}`,
'https://yoursite.com/blog',
],
}),
}
);
For Vercel and Netlify, their respective on-demand revalidation APIs work the same way — pass the path, they bust the edge cache.
The end state: you publish a post → webhook fires → CDN cache purges for exactly 2 URLs → first visitor gets fresh content → CDN re-caches the fresh version. Zero stale content, zero polling.
ISR vs SSG vs SSR with a Headless CMS
Quick orientation on which rendering strategy to pick:
| Strategy | When to use | CMS API calls |
|---|---|---|
| SSG (Static Site Generation) | Content changes rarely (once a week or less) | Once at build time |
| ISR (Incremental Static Regeneration) | Content changes daily, need fresh within minutes | Once at build + on revalidation trigger |
| SSR (Server-Side Rendering) | Content is personalized or must be live-accurate | Every request |
For a blog powered by a headless CMS, ISR with webhook-triggered revalidation is almost always the right answer. You get static performance (pages served from CDN edge) with content freshness within seconds of publishing.
Pure SSG is fine for rarely-updated content, but a full next build on every publish doesn't scale past ~50 posts without significant build-time investment.
SSR is the right choice only when content is user-specific (logged-in dashboard, personalized feed). Don't use it for public blog content — you're paying the latency cost of a CMS API call on every single visitor request when ISR with webhooks would give you identical freshness at CDN speed.
The detailed architecture breakdown is in Headless CMS Architecture: How the Frontend-Backend Split Works.
For REST vs GraphQL trade-offs at the API layer, see API-First CMS: REST vs GraphQL for Content Delivery.
FAQ
How many requests per minute does the UnfoldCMS API allow?
The api throttle middleware limits requests to 60 per minute, per IP. This applies to all /api/v1/* endpoints. Public read endpoints (GET requests for posts, pages, categories, settings) count toward the same bucket. If you're running CI/CD pipelines and dev environments against a shared CMS instance, you'll want to implement client-side caching as described above to stay within the limit.
What happens when you hit a rate limit?
The API returns 429 Too Many Requests with a Retry-After header (in seconds). Your HTTP client should respect this header and back off. Most fetch wrappers don't retry on 429 by default — you'll need to add this logic explicitly, or better, avoid hitting the limit through caching.
Do I need authentication to cache headless CMS responses?
No — the public read endpoints (GET /api/v1/posts, /api/v1/pages, etc.) require no authentication. This means you can cache these responses at any layer: CDN, server, or client. If you're fetching authenticated endpoints (draft content, admin routes), use token-scoped caching and never cache authenticated responses at a shared CDN level.
How do I know when my cache is stale?
With webhook-based invalidation, you don't have to guess — the CMS tells you exactly when content changed. Without webhooks, rely on TTL-based cache headers (Cache-Control: s-maxage=300) and accept up to 5 minutes of staleness for most content. For critical time-sensitive content (breaking news, live event pages), use SSR — accept the latency trade-off rather than serving stale.
Where to Go From Here
The mistake isn't that headless CMSes are hard to work with. It's that they're easy to use incorrectly in a way that only shows up under load.
The right mental model: treat the CMS API like a database. You wouldn't query your database on every HTTP request without connection pooling and query caching. Apply the same thinking to your CMS API — fetch once, cache with purpose, invalidate precisely on change.
- See all public API endpoints and filters in the docs
- Check which features ship out of the box vs. what you configure
- Read the full Complete Guide to Headless CMS in 2026 for the broader architecture context
Hamed Pakdaman is the founder of UnfoldCMS, a self-hosted Laravel + React CMS for developers. All API behavior described in this post is based on the live UnfoldCMS /api/v1/* implementation.
Free & Open Source
Own your CMS. No subscriptions.
Unfold CMS is free to download and self-host. Built on Laravel + React, full source code included.
Share this post: