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": "…",
      "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.
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

textBlock

Text block — A 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

features

Features — Three 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

productGrid

Product grid — A 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 }

testimonials

Testimonials — Quotes 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 }

faq

Questions & answers — Questions 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

cta

Call to action — A 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"

contact

Contact — Address, 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.

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.

app/[[...slug]]/page.tsx
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.

app/sitemap.ts and app/robots.ts
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.

claude_desktop_config.json (or .cursor/mcp.json)
{
  "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 content4 toolsLive pages and sections, via the read-only key
Websites3 toolsProjects, and the section registry with every field and limit
Pages6 toolsCreate, rename, re-address, reorder, delete — and the search settings
Sections7 toolsAdd, edit, hide, reorder, delete, preview — all on the draft
Media6 toolsUpload 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.