Getting started
Choosing an architecture
Where to read content from, which key to use, and how a site stays fresh without a redeploy.
Most of what goes wrong with a headless CMS is decided in the first ten minutes, by two choices: when you fetch content, and which key you fetch it with. Get them wrong and the site works perfectly on the day it is built and is broken as a product, because publishing an article no longer changes the website.
This page is the short version of both.
The mistake worth naming first
A static build that fetches everything at build time, with a secret key, and is never rebuilt again.
It is the natural thing to reach for. Astro, Next, Nuxt, SvelteKit and Eleventy all make build-time fetching the easy path, the content is right there in the HTML, and the result is genuinely fast. But:
The person who publishes an article sees nothing happen. Their post exists in ZevContent, the API serves it, and their site does not have it. They have to redeploy to publish. Nobody tells a marketing team that; they find out.
That is not a caching problem to tune later. It removes the reason the CMS is there. If a writer cannot publish without a developer, you have built a site with extra steps.
The three shapes that work
1. Static build plus a rebuild webhook. Fetch at build time, and register a webhook that triggers a rebuild. The site stays static and fast; publishing takes as long as a deploy. Best for a blog or docs site that changes a few times a week.
2. Server rendering with a cache. Render on the server, cache responses, and let a webhook clear the cache entries it names. Publishing shows up in seconds without a deploy. Best for anything with frequent edits.
3. Fetch in the browser. Read from the browser with a publishable key, for a widget or a search box on a page that is otherwise static. Simplest, and the only one where the key is exposed on purpose, which is what publishable keys are for.
Almost every real site is 1 or 2. What none of them are is “fetch everything at build time and never look again”.
Which key, and why it matters
There are three kinds, and choosing wrong is the single most expensive mistake available here.
| Kind | Prefix | Where it belongs | Reads | Writes |
|---|---|---|---|---|
| Secret | sk_ | A server, a build, a terminal. Never a browser. | Everything, drafts included | Yes |
| Publishable (web) | pk_ | Browser JavaScript, locked to your origins | Published only | No |
| Publishable (mobile) | pk_ | A mobile app, no origin lock | Published only | No |
A secret key in a browser is the whole project. It reads drafts, writes
content, changes the schema and deletes things. Shipping one in client-side
JavaScript, in a NEXT_PUBLIC_-style variable, or in a repo, hands every
visitor that power. Treat it the way you would a database password, because
that is what it is.
The rule that covers nearly every case:
Write with a secret key from a terminal or a server. Read from a deployed frontend with a publishable key.
Seeding a project is a one-off act by a developer or an agent, on a machine, so a secret key is right there and never leaves. The site that goes to production afterwards only needs to read content that is already published, so it wants a publishable key. That split means the credential your users’ browsers can see is one that can only read what is already public.
Server rendering is the one case where a secret key is the right answer for reads: the key stays on your server, and it is what lets you preview drafts. The moment any of that runs in a browser, it needs to be publishable.
Staying fresh without a redeploy
Cache reads, and let webhooks tell you when to stop.
Polling is the alternative and it is worse in both directions: too slow and your site is stale, too fast and you spend your rate limit discovering that nothing changed. A webhook says exactly what changed, once.
1. Register an endpoint in the dashboard, under Webhooks.
2. Cache what you read, keyed by the path you read it from.
3. On an event, invalidate what it names, and re-fetch on the next request.
Every event names its subject, so you can be precise rather than dropping everything:
| Event | Invalidate |
|---|---|
content.entry.* | The one page. data.url is the path to re-fetch. Plus any page that links to it: events do not cascade, so key your cache on the ids a page embeds, or ask referenced-by. |
content.type.* | Every page of that type. The response shape changed. |
blog.article.* · kb.article.* | That article, plus any list it appears on. |
media.asset.* | Every page showing that picture. |
release.published | Everything. A different release is live. |
The webhooks reference has the complete list, the payload and the signature check. Verify the signature before you trust a delivery: it is an HMAC of the raw body, and anybody can POST to a public URL.
Releases, and why they matter to a frontend
A release is a version of your whole project: its types, fields and content. One release is the default, and that is what the API serves to a key that does not ask for another.
This is how you make a large change without breaking the live site. Build the
new structure in a release, point a preview deployment at it with ?release=,
and promote it when it is right. Promoting is atomic: readers see the old
version, then the new one, never half of each.
For a frontend, two consequences:
- Production should not pin a release. Omit the parameter and you always serve the default, so promoting works without a deploy.
release.publishedis your biggest invalidation. Every page can change at once. This is the event that most justifies not building statically.
Custom content is release-scoped. The blog and knowledge base are not: an article is live or it is not, in every release, because a company blog is not something you want two versions of by accident.
A worked setup
The shape we would build, for a static site that publishes without a redeploy:
Seeding, once, from a terminal. A secret key in the shell, not in the repo.
Define types with PUT /v1/schema/content-types/{type}, upload images with
POST /v1/media/uploads, create entries. Nothing here ever ships.
The site, deployed. A publishable web key, origin-locked to your domain, in the environment. Fetch published content at build time. This key is in the bundle and that is fine: it reads published content and nothing else.
Freshness. A webhook pointed at your host’s build hook. Publishing triggers a rebuild; the writer sees their post live a minute later without asking anyone.
Changing the structure later. A new release, a preview deployment reading
it with ?release=rel_…, promote when it is right. The live site never sees
the intermediate state.
If the site needs edits to appear in seconds rather than minutes, swap the build-time fetch for server rendering with a cache and use the same webhook to invalidate instead of rebuild. Everything else stays the same.
Updated at, Sunday, September 20, 2026