Developer docs

Your design. Their words. No deploy in between.

Pagecraft serves content as plain JSON over four read-only endpoints. You write the React; your client writes the words and presses Publish; the live page regenerates in seconds. There is no template language, no theme system and nothing to learn beyond fetch.

How it fits together

A page is a list of sections. Each section has a type and a content object. You write one React component per type and map them together — the CMS never sends layout, colours, spacing or class names, so a client can rewrite every headline on the site and never change how any of it looks.

Client presses PublishCMS calls your webhookThat page rebuilds

Quick start

Five files. Start from an ordinary create-next-app, then add these. Your API key is on the Integration screen of any website in your dashboard, already filled in.

.env.local
PAGECRAFT_API_URL=https://api.mypagecraft.com
PAGECRAFT_API_KEY=pk_live_your_key_here
PAGECRAFT_WEBHOOK_SECRET=any-long-random-string

The key is read-only, scoped to one website, and serves published content only. It is safe in a client bundle — there is nothing to protect.

lib/cms.js
const API = process.env.PAGECRAFT_API_URL;
const KEY = process.env.PAGECRAFT_API_KEY;

export async function cms(path) {
  const res = await fetch(`${API}/api/content/${path}`, {
    headers: { "x-api-key": KEY },
    cache: "force-cache",
  });
  const json = await res.json();
  return json.success ? json.data : null;
}
components/sections/index.js
import { Hero } from "./hero";
import { Features } from "./features";
// …one per section type you support

export const components = { hero: Hero, features: Features };
app/[[...slug]]/page.jsx
import { cms } from "@/lib/cms";
import { components } from "@/components/sections";

export const dynamic = "force-static";

// Pages your client adds later still work: Next generates them on
// first request, with no redeploy.
export async function generateStaticParams() {
  const pages = await cms("pages");
  return pages.map((p) => ({ slug: p.slug ? p.slug.split("/") : [] }));
}

export default async function Page({ params }) {
  const { slug } = await params;
  const page = await cms(`pages/${slug?.join("/") || "index"}`);
  if (!page) return <h1>Not found</h1>;

  return page.sections.map((section) => {
    const Component = components[section.type];
    // An unmapped type renders nothing rather than crashing a live page.
    return Component ? <Component key={section.id} content={section.content} /> : null;
  });
}
app/api/revalidate/route.js
import { revalidatePath } from "next/cache";

export async function POST(req) {
  const body = await req.json();
  if (body.secret !== process.env.PAGECRAFT_WEBHOOK_SECRET) {
    return Response.json({ ok: false }, { status: 401 });
  }
  for (const path of body.paths ?? []) revalidatePath(path);
  revalidatePath("/", "layout");
  return Response.json({ ok: true });
}

Put that route's public URL and the same secret into Website settings → Publish webhook. That is the last wire; from then on, Publish updates the live site on its own.

The API

Every request carries the key as an x-api-key header, or ?key= in the query string. Responses are always { success: true, data } or { success: false, error }.

GET /api/content/pagesEvery published page: slug, title, order, seo
GET /api/content/pages/:slugOne page with its ordered, visible sections
GET /api/content/homeThe home page (its slug is the empty string)
GET /api/content/pages/:slug?preview=TOKENThe draft instead of the published copy

Use index as the slug for the home page. Sections arrive in order with hidden ones already removed, so you never sort or filter.

GET /api/content/pages/index
{
  "success": true,
  "data": {
    "slug": "",
    "title": "Home",
    "seo": { "metaTitle": "…", "metaDescription": "…" },
    "sections": [
      {
        "id": "10134a79-b640-486f-8e2d-d1b05c924d59",
        "type": "hero",
        "order": 0,
        "visible": true,
        "content": {
          "heading": "Your words, your website",
          "backgroundImage": { "url": "…", "width": 1600, "height": 1000, "alt": "…" },
          "buttons": [{ "label": "Book a table", "href": "/contact", "variant": "Solid" }]
        }
      }
    ],
    "publishedAt": "2026-08-28T18:04:11.402Z"
  }
}

Published responses are cached s-maxage=60, stale-while-revalidate=600; preview responses are no-store. The whole content API is rate limited to 120 requests per minute per IP — ample for a CDN-fronted site, and worth knowing if you statically build several hundred pages in one go.

Section types and their fields

These are generated from the same registry the API validates against, so they cannot drift. * marks a field the CMS refuses to publish without — your component can rely on it existing. A website only offers the types its owner has switched on.

heroBig photo, headline and a button at the top of a page.
heading *
text
string, up to 140 chars
subheading
para
string, up to 260 chars
backgroundImage
image
{ url, width, height, alt }
buttons
list
array, max 3
label *
text
string, up to 24 chars
href *
link
string
variant
select
"Solid" | "Outline"
showHours
toggle
boolean
textBlockA heading and one or more paragraphs of writing.
heading
text
string, up to 80 chars
paragraphs
list
array, max 8
body *
para
string, up to 600 chars
featuresThree to six short reasons to choose you, with icons.
heading
text
string, up to 80 chars
items
list
array, min 1, max 6
title *
text
string, up to 40 chars
description
para
string, up to 240 chars
bullets
list
array, max 4
text *
text
string, up to 60 chars
productGridA grid of products or services with photos and specs.
heading
text
string, up to 80 chars
categories
list
array, max 5
name *
text
string, up to 30 chars
products
list
array, max 24
photo
image
{ url, width, height, alt }
name *
text
string, up to 48 chars
description
para
string, up to 140 chars
category
text
string, up to 30 chars
specs
list
array, max 2
value *
text
string, up to 20 chars
label *
text
string, up to 30 chars
detailsUrl
link
string
specSheet
file
{ url, name, bytes }
galleryA grid of photos.
heading
text
string, up to 80 chars
images
list
array, max 24
photo
image
{ url, width, height, alt }
caption
text
string, up to 80 chars
testimonialsQuotes from happy customers.
heading
text
string, up to 80 chars
items
list
array, max 12
quote *
para
string, up to 320 chars
author *
text
string, up to 48 chars
role
text
string, up to 48 chars
avatar
image
{ url, width, height, alt }
faqQuestions and answers that open when clicked.
heading
text
string, up to 80 chars
items
list
array, max 20
question *
text
string, up to 120 chars
answer *
para
string, up to 600 chars
ctaA short band with one clear button.
heading *
text
string, up to 80 chars
subheading
para
string, up to 200 chars
buttons
list
array, max 2
label *
text
string, up to 24 chars
href *
link
string
variant
select
"Solid" | "Outline"
contactAddress, opening hours, map and a message form.
heading
text
string, up to 80 chars
intro
para
string, up to 300 chars
address
text
string, up to 160 chars
phone
text
string, up to 32 chars
email
text
string, up to 80 chars
hours
list
array, max 7
days *
text
string, up to 24 chars
time *
text
string, up to 24 chars
showForm
toggle
boolean

Need one that is not here? A bespoke section type is built to your design and appears in the client's dashboard like any other — see pricing.

Worth knowing

An unmapped section type must render nothing

Never throw on one. A client can add a section the day before you ship its component, and a live page must not go down because of it.

Survive a CMS outage at build time

Catch a network failure in the catch-all and render a holding page rather than failing the deploy. Do let a 401 or a 500 fail the build — that is a real misconfiguration you want to hear about.

Images may have no dimensions

width and height are 0 when the library never measured the file — an SVG, say. Omit the attributes in that case; width={0} tells a browser to render nothing.

Preview tokens last 30 minutes

They are scoped to one page and swap in that page's draft. Mint one from the dashboard's Preview button.

Plain React works too

No Next.js required. Fetch at runtime from a Vite app and let the cache headers do the work; edits appear on the next page load instead of instantly.

Get a key and try it

Create a website, add a page, press Publish, and point your local dev server at it. Fourteen days free, no card.