Connect a headless CMS to any frontend
Any frontend that can make an HTTP request can read content from HelmCMS. You create a site API key, call the read-only delivery API, and let webhooks tell your site when to refresh.
By the HelmCMS team · Reviewed against the HelmCMS codebase on October 29, 2026
The three pieces you need
| Piece | What it is | Where you get it |
|---|---|---|
| Delivery API | A read-only REST API at /delivery/v1 that returns published entries only | Part of every HelmCMS install |
| Site API key | A live key reads published content; a preview key can also read drafts. The key decides the site, so URLs never contain a site id. | Site settings → API keys (shown once, stored hashed) |
| Webhook | A signed POST to your site on entry.published, entry.unpublished and entry.deleted, with retries and a delivery log | Site settings → Webhooks (secret shown once) |
Reading content
List a model's published entries with GET /delivery/v1/entries/<model>, or fetch one by id or slug with GET /delivery/v1/entries/<model>/<id-or-slug>. Useful query parameters:
limit(1 to 100) andcursorfor paging; the response carriesnextCursor.sortandfilter[field]=valueto order and narrow results.depth(0 to 2) to include related entries; media fields always come back as URL, size and alt text.tree=trueon tree models (menus) to get nestedchildren.
Successful reads with a live key are cacheable for 60 seconds; anything read with a preview key is never cached, so drafts cannot leak into a shared cache.
Next.js (App Router)
Fetch with a cache tag, then clear that tag from a webhook route when an editor publishes.
// lib/cms.ts — read published posts from HelmCMS
const API = process.env.HELM_API_URL; // e.g. https://api.your-domain.com
const KEY = process.env.HELM_LIVE_KEY; // a "live" site API key
export async function getPosts() {
const res = await fetch(`${API}/delivery/v1/entries/blog-post?limit=20&depth=1`, {
headers: { Authorization: `Bearer ${KEY}` },
next: { tags: ["cms"] },
});
if (!res.ok) throw new Error(`CMS ${res.status}`);
const { data, nextCursor } = await res.json();
return { posts: data, nextCursor }; // each item: { id, slug, type, publishedAt, updatedAt, data }
}// app/api/cms-webhook/route.ts — refresh pages when an editor publishes
import { revalidateTag } from "next/cache";
async function hmacHex(secret: string, message: string) {
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function POST(req: Request) {
const body = await req.text();
const ts = Number(req.headers.get("X-CMS-Timestamp"));
const sent = req.headers.get("X-CMS-Signature") ?? "";
// Reject old deliveries (replays): allow 5 minutes of clock difference.
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return new Response("stale", { status: 401 });
const expected = "sha256=" + (await hmacHex(process.env.HELM_WEBHOOK_SECRET!, `${ts}.${body}`));
if (sent !== expected) return new Response("bad signature", { status: 401 });
revalidateTag("cms");
return new Response(null, { status: 204 });
}The signature is sha256= plus the hex HMAC-SHA256 of <timestamp>.<raw body>, using the webhook secret. Checking the timestamp stops someone from replaying an old delivery.
Astro
Astro pages can fetch at build time or on request; the call is the same.
---
// src/pages/blog/[slug].astro — one published post by slug
const { slug } = Astro.params;
const res = await fetch(
`${import.meta.env.HELM_API_URL}/delivery/v1/entries/blog-post/${slug}?depth=1`,
{ headers: { Authorization: `Bearer ${import.meta.env.HELM_LIVE_KEY}` } },
);
if (res.status === 404) return Astro.redirect("/404");
const { data: post } = await res.json();
---
<h1>{post.data.title}</h1>Draft previews
Give your preview environment a preview key and add ?preview=true to read the latest drafts. A live key that asks for drafts gets a 403, so a misconfigured production site fails loudly instead of showing unpublished work.
Other frontends
Nuxt, SvelteKit, Remix, a mobile app or a static site generator all work the same way: one HTTP GET with a bearer key. Set the allowed origins in site settings if the browser calls the API directly; server-side calls need no CORS setup.
In this topic
CMS API: read content from any frontend
Read published content from any frontend
Signed webhooks for publish events
Signed events so your site refreshes itself
A CMS for developers: model once, read anywhere
Model once, read from any framework
Mobile app CMS: structured content for every screen
Structured content for any client app