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": "…",
"ogImage": "https://media.example.com/…",
"canonicalUrl": "",
"noIndex": false
},
"updatedAt": "2026-09-02T09:14:00.000Z",
"publishedAt": "2026-09-02T09:14:03.000Z",
"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.
hero
Hero — Big photo, headline and a button at the top of a page.
textBlock
Text block — A heading and one or more paragraphs of writing.
features
Features — Three to six short reasons to choose you, with icons.
productGrid
Product grid — A grid of products or services with photos and specs.
gallery
Gallery — A grid of photos.
testimonials
Testimonials — Quotes from happy customers.
faq
Questions & answers — Questions and answers that open when clicked.
cta
Call to action — A short band with one clear button.
contact
Contact — Address, 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.
Search & sharing
Every page carries a seo block the owner edits in their dashboard, and the SDK turns it into tags for you. Nothing here needs the owner to have filled anything in: a blank metaTitle falls back to the page title, a blank description is written from the page's own opening sentence, and the canonical defaults to the page's own address.
import { pageMetadata, pageJsonLd } from "@mypagecraft/sdk";
import { JsonLd } from "@mypagecraft/sdk/react";
const seo = { siteUrl: "https://acme.com", siteName: "Acme" };
export async function generateMetadata({ params }) {
const { slug } = await params;
const page = await cms.getPage(slug?.join("/") ?? "");
return pageMetadata(page, seo); // title, description, canonical,
} // robots, og:* and twitter:*
export default async function Page({ params }) {
const page = await cms.getPage(/* … */);
return (
<>
<JsonLd data={pageJsonLd(page, seo)} />
<SectionRenderer sections={page.sections} components={components} />
</>
);
}The structured data writes itself. pageJsonLd reads the sections the owner already filled in — an faq section becomes a FAQPage (the answers can appear directly in the search result), a contact section becomes a LocalBusiness with its address, phone and opening hours, and a productGrid becomes an ItemList of products. A page with none of those simply gets a WebPage node. Nobody is asked to type their address twice.
A sitemap and a robots.txt come from the same page list you already fetch for the navigation, so neither costs an extra request. Pages the owner has hidden from search are left out of the sitemap rather than listed and then disallowed — listing a noindex URL is reported in Search Console as an error against the whole file.
import { sitemapEntries, robotsTxt } from "@mypagecraft/sdk";
export default async function sitemap() {
return sitemapEntries(await cms.getPages(), seo);
}
// app/robots.ts
export default function robots() {
return { rules: [{ userAgent: "*", allow: "/" }], sitemap: `${seo.siteUrl}/sitemap.xml` };
}Not on Next.js? metaTags(page, seo) returns the same facts as plain data and renderMetaTags turns them into HTML, while renderSitemap and robotsTxt return finished strings you can serve from any framework.
Let a coding agent do it (MCP)
Everything above describes what you build. This is the part you do not have to. @mypagecraft/mcp is an MCP server that hands a website to Claude, Cursor, Copilot or anything else that speaks the protocol — 26 tools to read section types, draft pages, fill sections, upload photos, write alt text and publish.
It is a client of this same REST API and nothing more: no database connection, no privileged path, no second set of rules. The section registry, the field limits, the draft-until-Publish gate and which website you may touch are all enforced by the API exactly as they are for the dashboard. That is what makes it safe to hand an agent — it can only do what a person with the same credentials could do.
{
"mcpServers": {
"pagecraft": {
"command": "npx",
"args": ["-y", "@mypagecraft/mcp"],
"env": {
"PAGECRAFT_API_URL": "https://api.yoursite.com",
"PAGECRAFT_PROJECT_TOKEN": "pct_…",
"PAGECRAFT_PROJECT_ID": "…"
}
}
}
}Use a project token, not a password. Mint one on a website's Integration screen: it authorises writes to that one website, reaches nothing else, never expires, and is revocable without a password reset. An account email and password also works and unlocks every website that account can reach — use it only when you genuinely need that, and then from an account that owns only what you are happy for an assistant to edit. A website's public API key is read-only by design, so it unlocks the four published-content tools and no others.
Tools that cannot be used are never advertised. A key-only config is offered four tools rather than 26 that fail, and PAGECRAFT_READ_ONLY=1 removes all sixteen write tools from the list entirely. A model cannot reach for a tool it cannot see, which is a stronger guarantee than a refusal.
| Published content | 4 tools | Live pages and sections, via the read-only key |
| Websites | 3 tools | Projects, and the section registry with every field and limit |
| Pages | 6 tools | Create, rename, re-address, reorder, delete — and the search settings |
| Sections | 7 tools | Add, edit, hide, reorder, delete, preview — all on the draft |
| Media | 6 tools | Upload a local file end to end, alt text, delete |
pagecraft_publish_page is the only tool that changes what a visitor sees. Everything else writes the draft, so an agent that misunderstands you produces a draft you can read, correct or throw away with pagecraft_discard_draft — never a live page you have to explain.
Signed in to the dashboard, the “Connect your coding agent” pill hands you the same config with this website's real address, token and id already filled in. The full reference — every tool, every variable and the known gaps — lives in the package's README.
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.$7.99 a month for one website, $7.99 for each one after that.