Build a Next.js site on a content library
This walks through a Next.js (App Router) site whose every page, menu and setting comes from a Forumulate content library: one catch-all route renders any page a marketer publishes, the header and footer read globals, and a publish shows on the live site within seconds — no rebuild, no redeploy.
The finished site is in our repository as templates/marketing-next; copy it rather than typing this out. Use Forumulate as your website's CMS explains the concepts (libraries, collections, globals, routes, keys) this page uses.
Before you start
A content library made from the Website template (Content → New library → Website, or
POST /orgs/{orgId}/content/librarieswith"template": "website"). Note its id.A delivery key for it (
cfd_…, Content → Keys). Draft previews need no key: they run on an editor's preview session (see Preview).A home page: a
pageentry and aroutewith path/pointing at it, both published. The reference site's seed script creates a whole sample site for you — see its README.
npm install @forumulate/next @forumulate/embed @forumulate/blocks-react.env.local:
FORUMULATE_API_URL=https://forumulate.com/api
FORUMULATE_LIBRARY_ID=… # the id, not the slug
FORUMULATE_DELIVERY_KEY=cfd_…
FORUMULATE_PREVIEW_KEY=cfv_… # optional
FORUMULATE_WEBHOOK_SECRET=… # from the webhook you add belowUse the library id. The publish webhook names what changed with cache tags built from library ids; a site configured with the slug renders fine and then never updates.
The client
One module holds every read. createContent tags each fetch with the API's cache tags, so a publish can drop exactly the cached reads it made stale, and in draft mode it reads drafts instead, uncached.
// lib/cms.ts
import { createContent } from '@forumulate/next/content';
export const cms = createContent({
library: process.env.FORUMULATE_LIBRARY_ID!,
key: process.env.FORUMULATE_DELIVERY_KEY!,
baseUrl: process.env.FORUMULATE_API_URL,
// The webhook makes edits appear in seconds; this is the safety net if one is missed.
revalidate: 300,
});createContentClient({ library, key }) from @forumulate/embed/content is the same client without Next's caching — for scripts, other frameworks, or a route createContent does not wrap (the reference site reads GET …/routes with it).
The catch-all route
Every page goes through one file. The path is looked up in the library's routes and the entry is rendered with <Content>, which knows how to draw every field type from the values alone:
// app/[[...path]]/page.tsx
import { notFound, permanentRedirect, redirect } from 'next/navigation';
import { Content } from '@forumulate/blocks-react';
import { cms } from '@/lib/cms';
type Props = { params: Promise<{ path?: string[] }> };
const toPath = (segments?: string[]) => `/${(segments ?? []).map(decodeURIComponent).join('/')}`;
// Paths that exist at build time are prerendered...
export async function generateStaticParams() {
const routes = await cms.all('route', { select: ['path'] });
return routes.map((r) => ({ path: String(r.fields.path).split('/').filter(Boolean) }));
}
// ...and a page published later renders on its first visit, then stays cached.
export const dynamicParams = true;
export default async function Page({ params }: Props) {
const hit = await cms.resolveRoute(toPath((await params).path));
if (!hit) notFound();
if (hit.kind === 'redirect') {
if (hit.status === 301 || hit.status === 308) permanentRedirect(hit.to);
redirect(hit.to);
}
return <Content entry={hit.entry} />;
}That is the whole contract with your marketing team: they create a page, add a route, publish — the path works. Nothing in your code names /pricing.
resolveRoute answers null for a path nothing is published at (a 404), { kind: 'redirect', to, status } when a redirect rule matches — including the ones recorded automatically when a page moved — and otherwise { kind: 'entry', route, entry } with references populated one level deep.
Your own components
<Content> renders everything with sensible markup, and you replace any part of it with your own design system — by block type, by field path, by field type, or a whole content type:
<Content
entry={hit.entry}
overrides={{
blocks: { hero: Hero, features: Features, cta: CallToAction },
contentTypes: { post: PostLayout },
}}
/>An override receives the block's values (block), its field declarations, and attrs to spread on its root element (the click-to-edit attribute in preview, nothing in production). A block type a marketer adds later, with no component of yours yet, still renders through the default.
Metadata
generateMetadata reads the same resolution (wrap the call in React's cache() so the page and its metadata make one request). The Website model has three layers of SEO — the route's seo overrides the entry's seo, which overrides the site global's defaults — and the entry's title is the last resort for <title>.
Menus, header, footer and settings
The root layout reads the globals, so a menu change is a publish, never a deploy:
// app/layout.tsx
const [site, header, footer] = await Promise.all([
cms.getGlobal('site'),
cms.getGlobal('header'),
cms.getGlobal('footer'),
]);
const mainMenu = header.fields.menu; // the `menu` entry, populated: { fields: { items: [...] } }Each menu item is { label, link, children }, nested up to three levels. A link is either { type: 'url', url } or { type: 'reference', reference } — an entry, whose route is its path. Links point at entries rather than paths so a page that moves keeps every menu item pointing at it. The reference site renders nested items as lists that open on hover and on keyboard focus (:focus-within), with no script.
The site global also carries the announcement bar (announcement.enabled, message, link), the logo, the favicon and social links.
Revalidation: edits live in seconds
Add the receiver:
// app/api/revalidate/route.ts
import { createRevalidateHandler } from '@forumulate/next/content';
export const POST = createRevalidateHandler({ secret: process.env.FORUMULATE_WEBHOOK_SECRET! });Then, in the library, add a webhook (Content → Webhooks, or POST /orgs/{orgId}/webhooks with "libraryId") for content.entry.published, content.entry.unpublished, content.entry.deleted and content.route.changed, with the URL https://your-site.example/api/revalidate. Put its signing secret in FORUMULATE_WEBHOOK_SECRET.
What happens on publish:
The webhook carries
tags— the same cache tags the delivery API puts on its responses (fm:entry:<id>,fm:type:<library>:<type>,fm:global:<library>:<key>,fm:route:<library>:<hash>) — andaffectedTags, every entry that references the changed one. Publish a menu, and the header that shows it is named too.The handler verifies the signature (HMAC, with a five-minute replay window) and calls
revalidateTagfor each tag.The next reader gets fresh content. Nothing else is refetched.
The URL must be public HTTPS — webhooks are never sent to localhost or a private network. While developing, use a tunnel, or rely on revalidate.
The handler takes onRevalidate(result) for anything extra. The reference site also revalidates the delivery's paths, for one case tags cannot reach: a path that answered 404 before its route existed was cached as a 404 page, and only the path names it.
A fully static export cannot revalidate; use a deploy hook instead, which starts a build on Vercel, Netlify or Cloudflare Pages (debounced, so five quick publishes are one build). See Webhooks.
Caching
There are three caches between an edit and a reader, and each is handled:
Your Next.js Data Cache holds every read, tagged. The webhook drops exactly the stale ones;
revalidateis the fallback.Our origin cache sits in front of the database, keyed by library, key scope and the normalised query, and is purged by tag on publish. During a refresh the previous answer is served, so a publish never causes a burst of slow requests.
Shared caches (CDNs) never hold a response to a secret key:
cfd_answers areprivate, max-age=0, must-revalidateandcfv_answersprivate, no-store. Only a publishable key, on the/delivery/v1/k/{keyId}/…form, getspublic, s-maxage=60, stale-while-revalidate=86400, because there the key is part of the URL and therefore of the cache key.
Every response has an ETag (repeat requests with If-None-Match get a 304) and a Cache-Tag / Surrogate-Key header listing its tags, so a CDN in front of your site can purge by the same tags the webhook sends.
Preview
Draft mode lets an editor see drafts on your real site before publishing. It runs on preview sessions, so there is no shared secret to hand around and no key for drafts in your environment:
An editor who can read the library presses Preview on the entry in the studio, which mints a preview token for that entry and the path it renders at, as them (scripts can do the same through
POST /orgs/{orgId}/content/libraries/{libraryId}/preview/sessionswith{ "entryId": "…", "path": "/pricing" }).The token (
cfvt_…) is single-use, valid for ten minutes, and bound to that library, entry and path.The token is POSTed as a form field — never put in a URL, where Referer headers, logs and history would keep it — to your site's preview route, with the same
path:/api/previewon the library's first preview origin, or the Preview route set under Settings → Delivery.Your route exchanges it server-side at
POST /delivery/v1/preview/exchangefor an hour-long preview credential (cfvs_…), keeps that in an httpOnly cookie, turns draft mode on and redirects to the path.
@forumulate/next ships both routes:
// app/api/preview/route.ts
import { createPreviewRoute } from '@forumulate/next/content';
export const POST = createPreviewRoute({ library: process.env.FORUMULATE_LIBRARY_ID });// app/api/exit-preview/route.ts
import { createExitPreviewRoute } from '@forumulate/next/content';
export const POST = createExitPreviewRoute();What they enforce, so you do not have to:
POST only, in and out. A
GETto either is a 405.Relative paths only. An absolute URL,
//evil.com, or a spelling a browser normalises into one (/\evil.com,/..//evil.com) is a 400, so the route can never be used as an open redirect.The cookie is
HttpOnly; Secure; SameSite=None; Partitionedand never outlives the credential. Inside the studio's frame your site is third-party; withoutPartitionedbrowsers drop the cookie and "preview" silently shows published content.Passing
librarymakes a token minted for any other library a 401. A reused, expired or wrong-path token is the same 401, so a prober learns nothing from the difference.
While draft mode is on, every read from createContent uses the credential, asks for drafts and skips the Data Cache — a draft never lands in a cache the next reader reads from. The credential reads only that one library, and it stops working the moment the editor loses access to it. When it expires (after an hour) the site reads published content again until the editor opens the preview anew.
<Content preview>adds click-to-edit attributes to every field; leave it off in production (the reference site passespreview={isDraft}).Show a banner while previewing with an Exit preview form button that POSTs to
/api/exit-preview(add apathfield to choose where it lands;/otherwise). Make it a button, not a<Link>: Next prefetches links in the viewport, and a prefetched exit would end the preview as the banner rendered. The reference site's banner does this with no script.A preview key (
cfv_…,previewKeyincreateContent) still works for a server that must read drafts without an editor — a build of a staging site, say. It is not needed for preview, and it is a standing key to every draft, so prefer the session flow.
Sitemap and robots
GET …/routes lists every live path with its last change; app/sitemap.ts maps it to URLs. It is tagged like every other read, so a new route updates the sitemap on the same publish.
Checklist before launch
The library id (not the slug) in
FORUMULATE_LIBRARY_ID.The delivery key only in server code — never in a
NEXT_PUBLIC_variable.A webhook pointing at
/api/revalidate, and a test publish showing up.A route for
/, and anot-found.tsxfor paths nothing is published at.site.seofilled in: it is every page's fallback title and description.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Preview Sessions
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Mint a preview token for an entry |
|