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.
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.
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.
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;
}import { Hero } from "./hero";
import { Features } from "./features";
// …one per section type you support
export const components = { hero: Hero, features: Features };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;
});
}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/pages | Every published page: slug, title, order, seo |
| GET /api/content/pages/:slug | One page with its ordered, visible sections |
| GET /api/content/home | The home page (its slug is the empty string) |
| GET /api/content/pages/:slug?preview=TOKEN | The 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.
{
"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.
textBlockA heading and one or more paragraphs of writing.
featuresThree to six short reasons to choose you, with icons.
productGridA grid of products or services with photos and specs.
galleryA grid of photos.
testimonialsQuotes from happy customers.
faqQuestions and answers that open when clicked.
ctaA short band with one clear button.
contactAddress, opening hours, map and a message form.
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.