Build a Next.js site on a content library

Embed
Preview of the card
<a href="https://forumulate.com/guides/headless-cms-nextjs"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/6ea8d755-12e9-4039-9121-60195e5ce0d9/social-card.svg?v=3e7ae33eec8b" alt="Build a Next.js site on a content library" width="600" height="315"></a>

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

  1. A content library made from the Website template (Content → New library → Website, or POST /orgs/{orgId}/content/libraries with "template": "website"). Note its id.

  2. A delivery key for it (cfd_…, Content → Keys). Draft previews need no key: they run on an editor's preview session (see Preview).

  3. A home page: a page entry and a route with 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 below

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

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:

  1. 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>) — and affectedTags, every entry that references the changed one. Publish a menu, and the header that shows it is named too.

  2. The handler verifies the signature (HMAC, with a five-minute replay window) and calls revalidateTag for each tag.

  3. 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; revalidate is 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 are private, max-age=0, must-revalidate and cfv_ answers private, no-store. Only a publishable key, on the /delivery/v1/k/{keyId}/… form, gets public, 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:

  1. 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/sessions with { "entryId": "…", "path": "/pricing" }).

    The token (cfvt_…) is single-use, valid for ten minutes, and bound to that library, entry and path.

  2. 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/preview on the library's first preview origin, or the Preview route set under Settings → Delivery.

  3. Your route exchanges it server-side at POST /delivery/v1/preview/exchange for 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 GET to 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; Partitioned and never outlives the credential. Inside the studio's frame your site is third-party; without Partitioned browsers drop the cookie and "preview" silently shows published content.

  • Passing library makes 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 passes preview={isDraft}).

  • Show a banner while previewing with an Exit preview form button that POSTs to /api/exit-preview (add a path field 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_…, previewKey in createContent) 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 a not-found.tsx for paths nothing is published at.

  • site.seo filled 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

PreviewSessions_mint

POST /orgs/{orgId}/content/libraries/{libraryId}/preview/sessions

Mint a preview token for an entry

client.content.previewSessions.mint()

The Forumulate teamUpdated