---
title: Choosing an architecture
description: 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.

> **WARNING: If you are an agent reading this**
>
> Do not ship a build-time-only setup unless the person asked for one and
> understands the trade. The default assumption of anybody installing a CMS is
> that publishing updates the site. Wire the webhook, or render on demand.

## 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.

> **WARNING: A web key with no origins listed is not locked**
>
> The origin lock is not automatic. A `publishable_web` key is only restricted
> **when you list origins on it**; leave that list empty and it is accepted from
> anywhere, exactly like a mobile key. So "web key" does not mean "protected",
> listing the origins does.
>
> Add every origin you serve from, with scheme and port:
> `https://example.com`, `http://localhost:4321`. A copy of the key lifted from
> your bundle then fails from somebody else's site.
>
> A `publishable_mobile` key has no lock by design, because a mobile app sends
> no `Origin` header. Use it for apps. Do not reach for it to avoid configuring
> origins on a website, and do not leave a web key's list empty for the same
> reason.
>
> Worth knowing exactly what the lock does, because it is narrower than it
> sounds. The check only runs when a request **arrives with an `Origin`
> header**, which in practice means a browser. Measured against an origin-locked
> key:
>
> | request | result |
> |---|---|
> | `Origin: https://example.com` (listed) | 200 |
> | `Origin: https://evil.test` (not listed) | 403 |
> | **no `Origin` header at all** | **200** |
>
> So an origin-locked key **does** work for a server-rendered frontend, and for
> `curl`, and for anything else that is not a browser. That is the intended
> behaviour: the lock exists to stop a key lifted from your bundle working on
> somebody else's site, which is a thing only a browser can be stopped from
> doing. Treat it as protection against key theft, not as authentication.

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](/api/webhooks) 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.

> **WARNING: A webhook is a hint, not a transaction**
>
> Deliveries retry, so treat handlers as idempotent, and do not assume one
> arrives for every change: an endpoint that is down long enough is disabled.
> Re-fetch on the event rather than trusting its contents as the new state, and
> you are correct either way.

## 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.published` is 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.