<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Shapio blog</title><description>Guides for building on Shapio, and arguments about how a CMS should work. Releases live in the changelog.</description><link>https://shapio.dev/blog/</link><language>en</language><item><title>Content that is code: using Shapio&apos;s code field</title><link>https://shapio.dev/blog/content-that-is-code/</link><guid isPermaLink="true">https://shapio.dev/blog/content-that-is-code/</guid><description>You will have a code field for a game&apos;s dialogue script, an app&apos;s config and a site&apos;s head tag, and know how each one reaches its client.</description><pubDate>Thu, 08 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Would you open this value in a code editor? Then it is a code field.&lt;/p&gt;&lt;p&gt;A dialogue script, a runtime configuration file or an HTML embed is content that is code. It has syntax. Its whitespace may matter. Another program consumes it. A product&amp;#39;s price or a post&amp;#39;s title belongs in a normal field.&lt;/p&gt;&lt;p&gt;Shapio&amp;#39;s new &lt;code&gt;code&lt;/code&gt; field stores a string. It never trims, reformats or rewrites that string. Bytes in equal bytes out. The language setting tells the editor and API what kind of text the field contains.&lt;/p&gt;&lt;p&gt;Here are three ways to use it, starting with the program that consumes the content.&lt;/p&gt;&lt;h2 id=&quot;a-game-dialogue-written-by-the-writer&quot;&gt;A game: dialogue written by the writer&lt;/h2&gt;&lt;p&gt;A writer can keep a dialogue script in the CMS. The game downloads the string and passes it to its dialogue runtime for parsing.&lt;/p&gt;&lt;p&gt;For example, an ink script might contain:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;=== gatekeeper ===
The gatekeeper lowers her lantern.
* [Ask about the road]
    &amp;quot;Stay east of the river.&amp;quot;
    -&amp;gt; END
* [Leave]
    -&amp;gt; END&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Create a code field and set its language to &lt;code&gt;plain&lt;/code&gt;. Ink, Yarn and Lua are not language options yet. Plain gives the writer a monospaced editor and line numbers, without syntax highlighting.&lt;/p&gt;&lt;p&gt;That choice does not change the stored script. Shapio preserves the line breaks, indentation and punctuation exactly as entered. The game receives the string through REST and gives it to the runtime that understands ink.&lt;/p&gt;&lt;p&gt;The API also describes the field&amp;#39;s language. With &lt;code&gt;plain&lt;/code&gt;, that metadata identifies plain code text, not ink specifically. The game still needs to know which dialogue parser to use. The language setting describes the field; it does not select a runtime.&lt;/p&gt;&lt;p&gt;The admin uses CodeMirror. Tab moves focus to the next control, just as it does elsewhere in the admin. To indent, use Cmd-] and Cmd-[, or Ctrl-] and Ctrl-[ on Windows and Linux.&lt;/p&gt;&lt;h2 id=&quot;an-app-or-engine-configuration-its-runtime-already-reads&quot;&gt;An app or engine: configuration its runtime already reads&lt;/h2&gt;&lt;p&gt;A code field also fits configuration that an app or engine already reads as JSON or YAML. Think drop rates, feature flags or wave timings.&lt;/p&gt;&lt;p&gt;For a JSON configuration field, set the language to &lt;code&gt;json&lt;/code&gt; and turn on Require valid JSON. An editor could maintain:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;dropRate&amp;quot;: 0.15,
  &amp;quot;features&amp;quot;: {
    &amp;quot;bonusRound&amp;quot;: true
  },
  &amp;quot;waveTimings&amp;quot;: [0, 15, 30]
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The runtime downloads the string and parses it as it would its existing JSON configuration. Shapio does not turn those keys into separate fields. The value remains one string.&lt;/p&gt;&lt;p&gt;Now suppose someone removes the comma after the drop rate:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;{
  &amp;quot;dropRate&amp;quot;: 0.15
  &amp;quot;features&amp;quot;: {
    &amp;quot;bonusRound&amp;quot;: true
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;With the parse check enabled, the field shows &amp;quot;Must be valid JSON&amp;quot; before saving. The server refuses the invalid value too. That bad edit cannot become the saved configuration that the app downloads.&lt;/p&gt;&lt;img src=&quot;https://itsasite.com/api/media/f/public/88e4aa6d-fc90-4dbf-8170-55c619f4a38a/e457177c2b3302ea9ebb89a2/code-field-json-invalid-v2.png&quot; alt=&quot;A JSON config field showing Must be valid JSON&quot;&gt;&lt;p&gt;The check establishes that the value parses as JSON. It does not establish that a drop rate is sensible or that the runtime recognises a feature name. Those are separate concerns for the consuming app.&lt;/p&gt;&lt;p&gt;Use &lt;code&gt;yaml&lt;/code&gt; when the runtime already reads YAML. Syntax highlighting follows the field&amp;#39;s language, and its language pack loads only when a field uses it. The Require valid JSON option is for JSON fields.&lt;/p&gt;&lt;h2 id=&quot;a-website-an-editor-controlled-verification-tag&quot;&gt;A website: an editor-controlled verification tag&lt;/h2&gt;&lt;p&gt;For a website, the code might be a verification tag in the head or an analytics loader at the end of the body.&lt;/p&gt;&lt;p&gt;Create an HTML code field named &lt;code&gt;embed&lt;/code&gt;. An editor can enter a verification tag:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;meta name=&amp;quot;example-site-verification&amp;quot; content=&amp;quot;verification-token&amp;quot;&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;img src=&quot;https://itsasite.com/api/media/f/public/43dd806c-9b4d-47a9-a195-58b859a62ae7/c9f0df700b06610727883c05/code-field-html-editor.png&quot; alt=&quot;An HTML code field in the Shapio admin&quot;&gt;&lt;p&gt;In an Astro page, render the string into the head. Here, &lt;code&gt;site&lt;/code&gt; is the content object your page has fetched:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
const { site } = Astro.props;
---

&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&amp;quot;utf-8&amp;quot; /&amp;gt;
    &amp;lt;Fragment set:html={site.embed ?? &amp;quot;&amp;quot;} /&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;slot /&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The same approach can place an editor-maintained script near the closing body tag.&lt;/p&gt;&lt;p&gt;On a static site, the change goes live after the next build, not instantly. Adding the field itself needs no rebuild, restart or deploy of Shapio. Models change live in the admin while Shapio runs. The website&amp;#39;s build is a separate step.&lt;/p&gt;&lt;h2 id=&quot;what-the-api-sees&quot;&gt;What the API sees&lt;/h2&gt;&lt;p&gt;In a schema file, an HTML code field uses:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;code&amp;quot;,
  &amp;quot;settings&amp;quot;: {
    &amp;quot;language&amp;quot;: &amp;quot;html&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Language is set once per field, not per entry. The options are &lt;code&gt;plain&lt;/code&gt;, &lt;code&gt;html&lt;/code&gt;, &lt;code&gt;css&lt;/code&gt;, &lt;code&gt;javascript&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;yaml&lt;/code&gt; and &lt;code&gt;markdown&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;REST returns the string. GraphQL returns &lt;code&gt;String&lt;/code&gt;, with a field description ending in &lt;code&gt;Code: html&lt;/code&gt;. OpenAPI adds &lt;code&gt;x-shapio-language&lt;/code&gt; and, for HTML, &lt;code&gt;contentMediaType: text/html&lt;/code&gt;. Generated TypeScript keeps the language in its doc comment:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;/** Embed (Code: html) */
embed: string | null;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Code fields cannot be unique, filterable or sortable. Changing long text to code preserves every value, but the planner flags the change as breaking because filtering is lost. Confirm it in the change set or use &lt;code&gt;--allow-breaking&lt;/code&gt; with &lt;code&gt;shapio schema apply&lt;/code&gt;. Changing code back to text is not breaking.&lt;/p&gt;&lt;p&gt;Use a code field when the content belongs in a code editor and a client consumes its syntax. Keep prices and titles in normal fields. Let the writer or editor maintain the source, let Shapio preserve its bytes, and let the game, app or website interpret it.&lt;/p&gt;</content:encoded><category>Guide</category></item><item><title>I didn&apos;t build redirects into my CMS</title><link>https://shapio.dev/blog/redirects-as-a-content-type/</link><guid isPermaLink="true">https://shapio.dev/blog/redirects-as-a-content-type/</guid><description>I took redirects off Shapio&apos;s feature list because a content type and a build step already do the job.</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-roadmap-card&quot;&gt;The roadmap card&lt;/h2&gt;&lt;p&gt;Redirects were a Now item on the shapio.dev roadmap. The card promised redirects “managed per site in the admin and written into your host&amp;#39;s redirect file at build time”. I decided not to build that feature.&lt;/p&gt;&lt;p&gt;Shapio is an open-source, self-hosted headless CMS. Its content model is data in the database, so you can change a type in the admin while production keeps running. The REST and GraphQL APIs serve a new field on the next request, without a rebuild, restart or deploy.&lt;/p&gt;&lt;p&gt;Redirects are website plumbing. The CMS does not need to know what a redirect is. It needs to store the entries, control who can read them and deliver them to the build.&lt;/p&gt;&lt;p&gt;What Strapi users add with a community plugin, and WordPress users with a plugin that has millions of installs, is a content type and a build step here. The existing model and delivery API can carry the data.&lt;/p&gt;&lt;h2 id=&quot;make-the-type&quot;&gt;Make the type&lt;/h2&gt;&lt;p&gt;In the admin, create a content type named &lt;code&gt;redirect&lt;/code&gt; (its plural API ID is &lt;code&gt;redirects&lt;/code&gt;). Give it these fields:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;from&lt;/code&gt;: short text, required, unique.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;to&lt;/code&gt;: short text, required.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;type&lt;/code&gt;: choice with the values &lt;code&gt;permanent&lt;/code&gt; (label 301) and &lt;code&gt;temporary&lt;/code&gt; (label 302), default &lt;code&gt;permanent&lt;/code&gt;. Choice values must be GraphQL names, so they cannot be numbers. The field cannot be called &lt;code&gt;status&lt;/code&gt;, because every entry already has a system field with that name.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;enabled&lt;/code&gt;: boolean, default true.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;note&lt;/code&gt;: long text.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;That takes two minutes, live, with no deploy. Give the site&amp;#39;s build token, through its delivery role, read access to the type. In the starter, that role is “Starter site (delivery)”.&lt;/p&gt;&lt;p&gt;Only published redirects reach the build, because the delivery API serves published content only. On an instance with several sites, the type belongs to the site that uses it.&lt;/p&gt;&lt;h2 id=&quot;write-the-file-at-build-time&quot;&gt;Write the file at build time&lt;/h2&gt;&lt;p&gt;The Astro starter already writes a &lt;code&gt;_headers&lt;/code&gt; file at build time for Cloudflare Pages and Netlify. Put &lt;code&gt;_redirects&lt;/code&gt; beside it in &lt;code&gt;dist&lt;/code&gt;. Both hosts read that file, with one rule per line in the form &lt;code&gt;from to status&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;In the Astro starter, &lt;code&gt;src/lib/shapio.ts&lt;/code&gt; has a &lt;code&gt;listAll&lt;/code&gt; helper that reads a collection page by page at the build&amp;#39;s pinned snapshot (one delivery page holds at most 100 entries). It always asks for SEO fields with the site&amp;#39;s defaults filled in, which Shapio refuses for a type without an SEO field. Export it, and move its &lt;code&gt;...query&lt;/code&gt; line below &lt;code&gt;seo: &amp;#39;resolved&amp;#39;&lt;/code&gt; so a caller can override that:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const listAll = async &amp;lt;T&amp;gt;(routeKey: string, query: DeliveryListQuery): Promise&amp;lt;T[]&amp;gt; =&amp;gt; {
  // …
    const result = await shapio().delivery.list&amp;lt;T&amp;gt;(routeKey, {
      richText: &amp;#39;html&amp;#39;,
      seo: &amp;#39;resolved&amp;#39;,
      ...query,
      snapshot,
      page,
      pageSize: PAGE_SIZE,
    });&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then add a page that writes the file. Astro turns &lt;code&gt;src/pages/redirects.txt.ts&lt;/code&gt; into &lt;code&gt;dist/redirects.txt&lt;/code&gt;, and a rename at the end of the build script makes it &lt;code&gt;_redirects&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/pages/redirects.txt.ts
import { listAll } from &amp;#39;../lib/shapio.js&amp;#39;;

type Redirect = { from: string; to: string; type: &amp;#39;permanent&amp;#39; | &amp;#39;temporary&amp;#39;; enabled: boolean };

const CODE = { permanent: 301, temporary: 302 } as const;

export const GET = async () =&amp;gt; {
  const rules = await listAll&amp;lt;Redirect&amp;gt;(&amp;#39;redirects&amp;#39;, { seo: &amp;#39;raw&amp;#39; });
  const lines = rules.filter((r) =&amp;gt; r.enabled).map((r) =&amp;gt; `${r.from} ${r.to} ${CODE[r.type]}`);
  return new Response(lines.join(&amp;#39;\n&amp;#39;) + &amp;#39;\n&amp;#39;);
};&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&amp;quot;build&amp;quot;: &amp;quot;ASTRO_TELEMETRY_DISABLED=1 astro build &amp;amp;&amp;amp; mv dist/redirects.txt dist/_redirects&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The pinned snapshot matters for the same reason it matters for the pages. Shapio&amp;#39;s change sets and publication snapshots let schema and content ship together, and a build can pin a snapshot so every page sees one consistent moment. The redirect list should come from that moment too.&lt;/p&gt;&lt;p&gt;There is no separate publishing path to add. With a deployment connection, publishing a redirect rebuilds the site the same way publishing a post does.&lt;/p&gt;&lt;h2 id=&quot;next-js-and-sveltekit&quot;&gt;Next.js and SvelteKit&lt;/h2&gt;&lt;p&gt;For Next.js, the same list goes into &lt;code&gt;redirects()&lt;/code&gt; in &lt;code&gt;next.config&lt;/code&gt;, which is already async. Map &lt;code&gt;permanent&lt;/code&gt; to &lt;code&gt;statusCode: 301&lt;/code&gt; and &lt;code&gt;temporary&lt;/code&gt; to &lt;code&gt;statusCode: 302&lt;/code&gt;; Next&amp;#39;s own &lt;code&gt;permanent: true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; send 308 and 307. Next reads &lt;code&gt;redirects()&lt;/code&gt; at build time, and the Next starter revalidates on publish instead of rebuilding, so a new redirect goes live with the next deploy. For SvelteKit, write &lt;code&gt;build/_redirects&lt;/code&gt; the same way as Astro.&lt;/p&gt;&lt;h2 id=&quot;the-missing-prompt&quot;&gt;The missing prompt&lt;/h2&gt;&lt;p&gt;There is an honest gap: the one-click “your slug changed, add a redirect” prompt. That needs the CMS to notice that a slug moved on a published entry. Creating a type and writing a file does not supply that prompt.&lt;/p&gt;&lt;p&gt;That is the one piece that may belong in core later, as a generic &amp;quot;field changed&amp;quot; event, not as a redirects feature.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;A redirect is a content type and a build step.&lt;/p&gt;&lt;/blockquote&gt;&lt;h2 id=&quot;the-test-for-core&quot;&gt;The test for core&lt;/h2&gt;&lt;p&gt;I now apply this test to roadmap items: does the work improve the model or the API for any consumer? That consumer might be a site, an app, a game or a script. Or does the work only help a website on one host?&lt;/p&gt;&lt;p&gt;Core takes the first. Starters take the second. Redirect file generation goes in the starter.&lt;/p&gt;</content:encoded><category>Guide</category></item><item><title>See saved drafts on your development server</title><link>https://shapio.dev/blog/see-saved-drafts-on-your-development-server/</link><guid isPermaLink="true">https://shapio.dev/blog/see-saved-drafts-on-your-development-server/</guid><description>You will have a local site that reads saved drafts, shows a Drafts badge, and lets you check changes without publishing.</description><pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Drafts mode lets your development server render the content saved in Shapio, including entries never published. You save in the admin and reload the page on your machine. Publish stays a production action: it updates your live site.&lt;/p&gt;&lt;h2 id=&quot;before-you-start&quot;&gt;Before you start&lt;/h2&gt;&lt;p&gt;You need a running Shapio with an owner account. Choose the starter for the framework you use: Next.js, Astro, or SvelteKit. All three render the same blog and support drafts mode.&lt;/p&gt;&lt;p&gt;You also need a delivery token whose role grants &lt;code&gt;read&lt;/code&gt; on the site&amp;#39;s models and Read drafts. The starter seed creates that role and token for you. Keep the dev token in your local environment. Never put it in production.&lt;/p&gt;&lt;h2 id=&quot;step-1-create-your-starter&quot;&gt;Step 1: Create your starter&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-shapio my-site --site next      # or astro, sveltekit
cd my-site&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This creates the framework app and the blog&amp;#39;s model files in &lt;code&gt;shapio/&lt;/code&gt;. Keep &lt;code&gt;--site&lt;/code&gt;: without it, &lt;code&gt;create-shapio&lt;/code&gt; creates a Shapio CMS project instead.&lt;/p&gt;&lt;h2 id=&quot;step-2-seed-the-content-and-dev-token&quot;&gt;Step 2: Seed the content and dev token&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SHAPIO_URL=http://localhost:4300 SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD=&amp;#39;…&amp;#39; npm run seed&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The seed applies models, creates and publishes content in English and French, and leaves one article as a draft. It writes &lt;code&gt;.env&lt;/code&gt;, including &lt;code&gt;SHAPIO_DEV_DELIVERY_TOKEN&lt;/code&gt; for the second delivery role, &lt;code&gt;&amp;lt;site&amp;gt; dev&lt;/code&gt;, which may read drafts.&lt;/p&gt;&lt;p&gt;The seed is idempotent: running it again resets the content. It signs in with a temporary admin API token and revokes that token when it ends.&lt;/p&gt;&lt;h2 id=&quot;step-3-check-the-delivery-role&quot;&gt;Step 3: Check the delivery role&lt;/h2&gt;&lt;p&gt;The seed already did this. If you create the role yourself, open Network → Roles → New role, choose kind Delivery, grant &lt;code&gt;read&lt;/code&gt; on the site&amp;#39;s models, and enable Read drafts. Then create a token bound to that role in Settings → API tokens; the tokens list marks it with a Drafts chip.&lt;/p&gt;&lt;p&gt;Read drafts covers every model the role reads. It adds no model of its own, so you still need the model grants. Delivery tokens can only read, whatever their role says.&lt;/p&gt;&lt;h2 id=&quot;step-4-turn-on-drafts-locally&quot;&gt;Step 4: Turn on drafts locally&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SHAPIO_DRAFTS=true&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Set this in your local &lt;code&gt;.env&lt;/code&gt;; the starter reads saved drafts with &lt;code&gt;SHAPIO_DEV_DELIVERY_TOKEN&lt;/code&gt; when it is set, otherwise with &lt;code&gt;SHAPIO_DELIVERY_TOKEN&lt;/code&gt;. Every page carries a Drafts badge in its corner, and drafts mode pins no snapshot.&lt;/p&gt;&lt;p&gt;Every read is fresh. Under Next.js, draft reads are never cached or tagged, and the Next starter&amp;#39;s &lt;code&gt;/api/revalidate&lt;/code&gt; route does nothing. A local build can also read drafts with this setting. Never set &lt;code&gt;SHAPIO_DRAFTS&lt;/code&gt; or put the dev token in a production environment.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Publishing updates your live site, so it is not how you check a change.&lt;/p&gt;&lt;/blockquote&gt;&lt;h2 id=&quot;step-5-start-the-development-server&quot;&gt;Step 5: Start the development server&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Save a change in the admin, then reload the page on your machine. The saved change should appear, with the Drafts badge still visible.&lt;/p&gt;&lt;p&gt;Drafts include autosaves and may not pass validation yet. A required field can be empty, so render drafts defensively. Drafts mode covers the whole site for developers; the admin&amp;#39;s Preview pane is for previewing one entry, and the starter&amp;#39;s &lt;code&gt;/preview/&lt;/code&gt; page is unchanged.&lt;/p&gt;&lt;h2 id=&quot;step-6-check-a-draft-read-directly&quot;&gt;Step 6: Check a draft read directly&lt;/h2&gt;&lt;p&gt;In a shell with &lt;code&gt;SHAPIO_URL&lt;/code&gt; and &lt;code&gt;SHAPIO_DEV_DELIVERY_TOKEN&lt;/code&gt; set to the values in &lt;code&gt;.env&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -H &amp;quot;Authorization: Bearer $SHAPIO_DEV_DELIVERY_TOKEN&amp;quot; \
  &amp;quot;$SHAPIO_URL/api/content/articles?publicationState=draft&amp;amp;populate=author&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The REST request asks for drafts with &lt;code&gt;publicationState=draft&lt;/code&gt; and returns each entry&amp;#39;s saved draft in the delivery shape, including entries never published. Look for &lt;code&gt;meta.publicationState: &amp;quot;draft&amp;quot;&lt;/code&gt;; relation targets and &lt;code&gt;populate&lt;/code&gt; follow drafts too, within the models your role may read.&lt;/p&gt;&lt;p&gt;For GraphQL, request &lt;code&gt;publicationState: DRAFT&lt;/code&gt;. With &lt;code&gt;@shapio/client&lt;/code&gt;, use &lt;code&gt;drafts: true&lt;/code&gt; on &lt;code&gt;createClient&lt;/code&gt;; with &lt;code&gt;@shapio/local&lt;/code&gt;, use it on &lt;code&gt;createLocalClient&lt;/code&gt;. The server requires Read drafts and the site must ask for drafts. Anonymous callers and end-user accounts can never read them.&lt;/p&gt;&lt;h2 id=&quot;check-it-worked&quot;&gt;Check it worked&lt;/h2&gt;&lt;p&gt;Check a saved edit after reloading and confirm the page shows the Drafts badge. The direct REST response should identify its publication state as draft and carry &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt;, so no cache keeps it.&lt;/p&gt;&lt;p&gt;Leave Publish for production. Published reads are unaffected; drafts mode gives your machine access to saved content without publishing it.&lt;/p&gt;&lt;h2 id=&quot;when-it-goes-wrong&quot;&gt;When it goes wrong&lt;/h2&gt;&lt;p&gt;If the token lacks Read drafts, the server returns &lt;code&gt;403 DRAFTS_FORBIDDEN&lt;/code&gt; with a message naming the grant. The starter&amp;#39;s first read fails: a build stops, or the dev server shows the message on its error page. It never falls back to published content. Check that the selected token belongs to the delivery role with Read drafts and the required model grants.&lt;/p&gt;&lt;p&gt;A draft cannot be pinned. Combining &lt;code&gt;publicationState=draft&lt;/code&gt; with &lt;code&gt;snapshot&lt;/code&gt; returns a 400. Check that your direct draft request does not also request a snapshot.&lt;/p&gt;</content:encoded><category>Guide</category></item><item><title>Drafts had to be a permission, not a flag</title><link>https://shapio.dev/blog/drafts-had-to-be-a-permission/</link><guid isPermaLink="true">https://shapio.dev/blog/drafts-had-to-be-a-permission/</guid><description>Testing saved content on a development server should require permission to read drafts, without turning Publish into a development tool.</description><pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;save-refresh-nothing&quot;&gt;Save, refresh, nothing&lt;/h2&gt;&lt;p&gt;I was editing my own site&amp;#39;s content in the Shapio admin with the site&amp;#39;s development server running. I saved a change, refreshed the page, and nothing changed. I asked whether I had to publish too.&lt;/p&gt;&lt;p&gt;The answer was yes. The site reads published content only, by design. Drafts never leak to the public API, so saving a draft had done exactly what it was supposed to do.&lt;/p&gt;&lt;h2 id=&quot;publish-meant-production&quot;&gt;Publish meant production&lt;/h2&gt;&lt;p&gt;My objection was simple: Publish is a production action. On my site, publishing fires a Cloudflare Pages rebuild of the live site. Using that button to test content on a development server would also ship it.&lt;/p&gt;&lt;p&gt;That left a gap between saving content and seeing it in the development site. Shapio already had per-entry preview tokens for the admin&amp;#39;s Preview pane. Those covered one entry for one hour.&lt;/p&gt;&lt;p&gt;A per-entry preview did not give the development server access to saved drafts across the site. I wanted to render those drafts while keeping Publish as the action that changes production. That distinction belongs in the product, even when the person editing the content also runs the site.&lt;/p&gt;&lt;h2 id=&quot;the-tempting-flag&quot;&gt;The tempting flag&lt;/h2&gt;&lt;p&gt;The wrong fix would have been a site setting: &lt;code&gt;SHAPIO_DRAFTS=true&lt;/code&gt;. Set it, ask the server for drafts, and have the server hand them over. It would have answered my immediate complaint.&lt;/p&gt;&lt;p&gt;It would also have made any site with that flag able to read any draft. The rule that public delivery never leaks drafts would then depend on a configuration value on someone else&amp;#39;s machine. That is an unacceptable place to enforce access.&lt;/p&gt;&lt;p&gt;The site can decide which content it wants to request. The server has to decide which content the caller is allowed to receive. Combining those decisions into one flag makes the access rule too easy to mistake for a development convenience.&lt;/p&gt;&lt;h2 id=&quot;two-safeguards&quot;&gt;Two safeguards&lt;/h2&gt;&lt;p&gt;The fix shipped on October 5, 2026, in release 0.5.1. Delivery roles gained a new permission, &amp;quot;Read drafts&amp;quot;. That permission covers every model the role can read.&lt;/p&gt;&lt;p&gt;The delivery API accepts &lt;code&gt;?publicationState=draft&lt;/code&gt;, and GraphQL accepts &lt;code&gt;publicationState: DRAFT&lt;/code&gt;. A token without the grant gets &lt;code&gt;DRAFTS_FORBIDDEN&lt;/code&gt;. Anonymous callers and end-user accounts can never read drafts.&lt;/p&gt;&lt;p&gt;That gives us two required safeguards: the grant on the server and the request from the site. The token must be allowed to read drafts, and the site must ask for them. A production token has no grant, so the flag alone shows nothing.&lt;/p&gt;&lt;p&gt;The starters use &lt;code&gt;SHAPIO_DRAFTS=true&lt;/code&gt; to turn on draft reads with a separate development token created by the seed. They also show a &amp;quot;Drafts&amp;quot; badge on every page. The development token is never put in a production environment.&lt;/p&gt;&lt;p&gt;The flag still exists, but it now expresses what the site is asking to render. It cannot grant itself access. Configuration selects the mode, while the server enforces the permission.&lt;/p&gt;&lt;p&gt;Draft responses also carry &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt;. The client has a &lt;code&gt;drafts: true&lt;/code&gt; option, and under Next.js those reads are never cached or tagged. Being allowed to read a draft never makes it cacheable.&lt;/p&gt;&lt;h2 id=&quot;relations-exposed-the-mistake&quot;&gt;Relations exposed the mistake&lt;/h2&gt;&lt;p&gt;The first draft of the plan had a per-model grant. Review caught the problem with it: related entries were only checked for plain read permission, so a token allowed drafts of posts could have pulled draft authors through a relation.&lt;/p&gt;&lt;p&gt;The grant became token-wide, covering every model the role reads. A caller&amp;#39;s ability to read drafts therefore applies across those models, including the related entries.&lt;/p&gt;&lt;p&gt;Plan review caught several problems like it before any code was written, and the change shipped in one release. The relation catch is the one I keep coming back to. It shows why the permission needs to cover the content a request can reach, rather than just the entry where the request starts.&lt;/p&gt;&lt;h2 id=&quot;who-enforces-it&quot;&gt;Who enforces it&lt;/h2&gt;&lt;blockquote&gt;&lt;p&gt;If the server cannot refuse it, it is not a permission, it is a hope.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;That is my test for where a thing belongs: who enforces it. A site can ask for drafts with a flag. The server must be able to say no.&lt;/p&gt;</content:encoded><category>Opinion</category></item><item><title>Move a Strapi 5 project to Shapio with the importer</title><link>https://shapio.dev/blog/move-a-strapi-5-project-to-shapio/</link><guid isPermaLink="true">https://shapio.dev/blog/move-a-strapi-5-project-to-shapio/</guid><description>You will have your Strapi 5 models, media and content in Shapio, as drafts plus a change set that publishes them when you ship it.</description><pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The importer turns Strapi content types into models, documents into entries, and uploads into media library assets. Every entry is created as a draft, and the ones that were published in Strapi are collected into a change set you review and ship.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Nothing goes live by itself.&lt;/p&gt;&lt;/blockquote&gt;&lt;h2 id=&quot;before-you-start&quot;&gt;Before you start&lt;/h2&gt;&lt;p&gt;You need a Strapi 5 project and a running Shapio instance. Only Strapi 5 exports are supported.&lt;/p&gt;&lt;p&gt;Open &lt;code&gt;/admin/&lt;/code&gt; on the instance. If no admin account exists yet, Setup asks for your name, email and a password of at least 12 characters, entered twice. Do it right after the first start: until then, anyone who can reach the server can claim it.&lt;/p&gt;&lt;p&gt;Create the token the import runs with. In the admin, open Settings → API tokens, choose New token, give it a name, pick the Admin role and choose Create. The token is shown only once: copy it.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export SHAPIO_URL=https://cms.example.com
export SHAPIO_TOKEN=&amp;quot;&amp;lt;the token&amp;gt;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run every &lt;code&gt;npx shapio&lt;/code&gt; command below in your Shapio project (the one &lt;code&gt;npm install @shapio/cms&lt;/code&gt; or &lt;code&gt;create-shapio&lt;/code&gt; set up), where &lt;code&gt;npx shapio&lt;/code&gt; finds the CLI. Anywhere else, including inside the Strapi project, use &lt;code&gt;npx -p @shapio/cms shapio&lt;/code&gt; instead.&lt;/p&gt;&lt;p&gt;If your Strapi content is localized, add the same locales in Settings → Locales (Add locale, then a code such as &lt;code&gt;fr&lt;/code&gt; and a name). The import refuses to start while one is missing.&lt;/p&gt;&lt;h2 id=&quot;step-1-export-your-strapi-project&quot;&gt;Step 1: Export your Strapi project&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx strapi export --file my-export --key &amp;quot;&amp;lt;the key&amp;gt;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run this in the Strapi project, then move the file next to your Shapio project. It writes &lt;code&gt;my-export.tar.gz.enc&lt;/code&gt;, encrypted with the key. Keep the key, and once you have planned the import, leave the export file where it is until the import is done: the map step checks that the file has not changed.&lt;/p&gt;&lt;h2 id=&quot;step-2-plan-the-import&quot;&gt;Step 2: Plan the import&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx shapio import strapi my-export.tar.gz.enc --plan ./import --key &amp;quot;&amp;lt;the key&amp;gt;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The plan runs offline and sends nothing anywhere. It unpacks the export into &lt;code&gt;./import/source&lt;/code&gt;, so the key is needed only once, writes the proposed models into &lt;code&gt;./import/schema&lt;/code&gt; and prints every model with its entry counts.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;Collection type / single type becomes collection / single type, API ID from the singular name, plural kept&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Component becomes component, API ID from its name&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;uid&lt;/code&gt; becomes &lt;code&gt;uid&lt;/code&gt; (unique)&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;float&lt;/code&gt; becomes &lt;code&gt;number&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;enumeration&lt;/code&gt; becomes &lt;code&gt;enum&lt;/code&gt;, or &lt;code&gt;string&lt;/code&gt; when the values are not valid API names&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;blocks&lt;/code&gt;, &lt;code&gt;richtext&lt;/code&gt; (Markdown) becomes rich text&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;media&lt;/code&gt; becomes &lt;code&gt;media&lt;/code&gt;, single or multiple, allowed kinds kept&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;relation&lt;/code&gt; becomes &lt;code&gt;relation&lt;/code&gt; (&lt;code&gt;one&lt;/code&gt; or &lt;code&gt;many&lt;/code&gt;) on the owning side&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Reserved names get a suffix, and the plan lists each one: Strapi&amp;#39;s example components &lt;code&gt;shared.media&lt;/code&gt; and &lt;code&gt;shared.rich-text&lt;/code&gt; become &lt;code&gt;mediaItem&lt;/code&gt; and &lt;code&gt;richTextItem&lt;/code&gt;. Every planned model has draft and publish on.&lt;/p&gt;&lt;p&gt;Validation rules are not carried over. Users, roles, API tokens, plugin content types, configuration and review workflows are not imported.&lt;/p&gt;&lt;h2 id=&quot;step-3-review-and-apply-the-schema&quot;&gt;Step 3: Review and apply the schema&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx shapio schema apply --dir ./import/schema --lock ./import/schema-lock.json&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Before applying, you can rename API IDs and labels or delete fields. Entries follow stable IDs, so renames are fine.&lt;/p&gt;&lt;p&gt;The models belong to the primary site, so your admin token works without &lt;code&gt;--site&lt;/code&gt;. To import into another site, plan with &lt;code&gt;--site &amp;lt;key&amp;gt;&lt;/code&gt;. To share the models with all sites, plan with &lt;code&gt;--shared&lt;/code&gt; and apply with a network admin token.&lt;/p&gt;&lt;h2 id=&quot;step-4-import-media-and-content&quot;&gt;Step 4: Import media and content&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx shapio import strapi --map ./import&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This uploads every upload file from the archive with its alternative text and caption, creates every document as a draft (referenced entries first), opens the change set and prints its link. It ends with a summary like this one:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Media: 11/11 uploaded. Entries: 18/18 created (3 stay drafts: they were not published in Strapi).&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A locale published in Strapi goes into the change set, and a draft-only locale stays a draft. The owning side of a relation is imported; you answer the inverse direction with a filter on the owning field.&lt;/p&gt;&lt;h2 id=&quot;step-5-review-and-ship-the-change-set&quot;&gt;Step 5: Review and ship the change set&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;https://cms.example.com/admin/s/default/changes/&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Open the link the map step printed (also under Changes in the admin). Each item shows its locale, the Publish action and its fields. Choose Ship, then Ship again in the confirmation. Everything goes live together as one snapshot, and the page shows &amp;quot;Shipped. Live as v1&amp;quot;.&lt;/p&gt;&lt;h2 id=&quot;check-it-worked&quot;&gt;Check it worked&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -H &amp;quot;Authorization: Bearer $SHAPIO_TOKEN&amp;quot; &amp;quot;$SHAPIO_URL/api/content/articles?populate=author&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Swap in the plural API ID of one of your models. Only entries that were published in Strapi come back, with their relations filled in. Add &lt;code&gt;&amp;amp;locale=fr&lt;/code&gt; to read another locale.&lt;/p&gt;&lt;p&gt;Check the rich text. Blocks map directly, with underline and strike-through dropped. In Markdown, strike-through loses its mark and raw HTML is unwrapped.&lt;/p&gt;&lt;h2 id=&quot;when-it-goes-wrong&quot;&gt;When it goes wrong&lt;/h2&gt;&lt;p&gt;&lt;code&gt;schema apply&lt;/code&gt; refuses with &lt;code&gt;FORBIDDEN_SCOPE&lt;/code&gt; if you planned with &lt;code&gt;--shared&lt;/code&gt; and the token belongs to one site. Use a network admin token, or plan again without &lt;code&gt;--shared&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;&lt;code&gt;--map&lt;/code&gt; refuses until the models exist, and names the missing ones and the apply command to run. It also refuses while a locale is missing, and names it: add it in Settings → Locales and run &lt;code&gt;--map&lt;/code&gt; again.&lt;/p&gt;&lt;p&gt;An existing model with the same API ID stops &lt;code&gt;schema apply&lt;/code&gt;. Rename the planned model&amp;#39;s API ID and plural API ID in its file, and any colliding component, then apply.&lt;/p&gt;&lt;p&gt;Re-running &lt;code&gt;--map&lt;/code&gt; skips finished work and retries what failed. It refuses if the export file changed since it was planned. &lt;code&gt;--plan&lt;/code&gt; never overwrites an import that has started: plan into a new directory to start over.&lt;/p&gt;&lt;p&gt;A Strapi 4 export is refused. Upgrade the project with &lt;code&gt;npx @strapi/upgrade major&lt;/code&gt;, then export again.&lt;/p&gt;&lt;p&gt;A set holds at most 2,000 items. A larger import opens numbered sets, &amp;quot;Import from Strapi (1/2)&amp;quot; and so on, which you ship one after another.&lt;/p&gt;&lt;p&gt;The rate limit is waited out automatically, but while a large &lt;code&gt;--map&lt;/code&gt; runs, the admin opened from the same address can answer &lt;code&gt;RATE_LIMITED&lt;/code&gt; for up to a minute.&lt;/p&gt;</content:encoded><category>Guide</category></item><item><title>Deploy a Shapio site to Cloudflare Pages and rebuild on publish</title><link>https://shapio.dev/blog/deploy-to-cloudflare-pages-and-rebuild-on-publish/</link><guid isPermaLink="true">https://shapio.dev/blog/deploy-to-cloudflare-pages-and-rebuild-on-publish/</guid><description>You will have a static Astro site on Cloudflare Pages that rebuilds when you publish, with deployment status tracked in Shapio.</description><pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Cloudflare builds your site from Git, and Shapio starts each build through a deploy hook. Shapio follows the build through Cloudflare&amp;#39;s API so you can see whether published content reached the website.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Publish once and the deploy hook does the rest.&lt;/p&gt;&lt;/blockquote&gt;&lt;h2 id=&quot;before-you-start&quot;&gt;Before you start&lt;/h2&gt;&lt;p&gt;You need a running Shapio with an owner account, installed through npm or Docker. Your Shapio must be reachable from the internet for Cloudflare to read content.&lt;/p&gt;&lt;p&gt;This guide uses the Astro starter, which produces static HTML. Next.js uses on-demand revalidation, while SvelteKit uses a full rebuild started by a deployment connection.&lt;/p&gt;&lt;h2 id=&quot;step-1-create-and-seed-your-site&quot;&gt;Step 1: Create and seed your site&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-shapio my-site --site astro
cd my-site&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This creates a standalone Astro project with the blog&amp;#39;s model files in &lt;code&gt;shapio/&lt;/code&gt;. Seed it against your running Shapio, using your address and owner credentials:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SHAPIO_URL=https://cms.example.com SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD=&amp;#39;…&amp;#39; npm run seed&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The seed applies the models, uploads placeholder images, creates English and French content, and writes &lt;code&gt;SHAPIO_URL&lt;/code&gt; and &lt;code&gt;SHAPIO_DELIVERY_TOKEN&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;. Open the admin: Content shows pages and articles, Models shows the models, and Media shows images.&lt;/p&gt;&lt;p&gt;Running the seed again resets the content; its temporary admin API token is revoked when it finishes. It also creates a &lt;code&gt;&amp;lt;site&amp;gt; dev&lt;/code&gt; token, written as &lt;code&gt;SHAPIO_DEV_DELIVERY_TOKEN&lt;/code&gt;, that can read drafts and must never go to production.&lt;/p&gt;&lt;h2 id=&quot;step-2-build-and-inspect-locally&quot;&gt;Step 2: Build and inspect locally&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build
npm run preview&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The preview serves &lt;code&gt;dist/&lt;/code&gt; and prints its address, &lt;code&gt;http://localhost:4321/&lt;/code&gt; unless that port is taken. The home page redirects to &lt;code&gt;/en/&lt;/code&gt;, and the language switch leads to &lt;code&gt;/fr/&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Each build reads the current publication snapshot once and pins it, sending it with every request as &lt;code&gt;?snapshot=N&lt;/code&gt;. Content published during the build waits for the next build; &lt;code&gt;/build.json&lt;/code&gt; shows the built snapshot.&lt;/p&gt;&lt;h2 id=&quot;step-3-connect-cloudflare-pages-to-git&quot;&gt;Step 3: Connect Cloudflare Pages to Git&lt;/h2&gt;&lt;p&gt;Push &lt;code&gt;my-site&lt;/code&gt; to your own GitHub or GitLab repository, then select that repository in Cloudflare:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Workers &amp;amp; Pages → Create → Pages → Connect to Git
Framework preset: None
Build command: npm run build
Build output directory: dist
Root directory: leave empty&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The Astro preset works too; both give &lt;code&gt;npm run build&lt;/code&gt; and &lt;code&gt;dist&lt;/code&gt;. In Production environment variables, set &lt;code&gt;NODE_VERSION&lt;/code&gt; to &lt;code&gt;24&lt;/code&gt; and &lt;code&gt;SHAPIO_URL&lt;/code&gt; to your Shapio&amp;#39;s address, for example &lt;code&gt;https://cms.example.com&lt;/code&gt;. Set &lt;code&gt;SHAPIO_DELIVERY_TOKEN&lt;/code&gt; as a secret, using the token the seed wrote to &lt;code&gt;.env&lt;/code&gt; or one from Settings → API tokens with a delivery role that reads &lt;code&gt;page&lt;/code&gt;, &lt;code&gt;article&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt; and &lt;code&gt;siteSettings&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Never set &lt;code&gt;SHAPIO_DRAFTS&lt;/code&gt; or &lt;code&gt;SHAPIO_DEV_DELIVERY_TOKEN&lt;/code&gt; in production. Click Save and Deploy: the first build runs and the site becomes live at &lt;code&gt;https://&amp;lt;project&amp;gt;.pages.dev&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;After the first build, turn on Settings → Builds → Build cache. The starter re-renders only the pages whose content changed and restores the rest from the previous build, and that needs the cache to survive between builds. Without it, every build is a full one.&lt;/p&gt;&lt;h2 id=&quot;step-4-create-the-hook-and-api-token&quot;&gt;Step 4: Create the hook and API token&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Workers &amp;amp; Pages → your Pages project → Settings → Builds → Deploy hooks → Add deploy hook
My Profile → API Tokens → Create Token → Custom token&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Create the hook for the production branch and copy its URL. Give the custom token Account → Cloudflare Pages → Read, limited to your account, and copy the token.&lt;/p&gt;&lt;p&gt;Note the project name and account ID. You can find the account ID in the Workers &amp;amp; Pages overview&amp;#39;s right-hand column or the 32-character hex string in the dashboard URL.&lt;/p&gt;&lt;h2 id=&quot;step-5-add-the-deployment-connection&quot;&gt;Step 5: Add the deployment connection&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Publishing → Deployments → New connection → Cloudflare Pages&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Enter the account ID, project name, deploy hook URL and API token, then select On publish and Manual. Test connection should pass: it checks that the token can read the project without starting a build.&lt;/p&gt;&lt;p&gt;You can enter the API token as &lt;code&gt;${ENV:SHAPIO_SECRET_CF_PAGES_TOKEN}&lt;/code&gt; instead. Set that variable for Shapio&amp;#39;s API and any dedicated worker; Shapio stores only the variable name and reads its value when needed.&lt;/p&gt;&lt;p&gt;With On publish selected, every publish starts a build. Bursts are coalesced, so ten publishes in a minute start one build.&lt;/p&gt;&lt;h2 id=&quot;step-6-set-up-draft-preview&quot;&gt;Step 6: Set up draft preview&lt;/h2&gt;&lt;p&gt;Set the connection&amp;#39;s preview URL template:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;https://&amp;lt;project&amp;gt;.pages.dev/preview/?model={modelKey}&amp;amp;id={entryId}&amp;amp;locale={locale}#token={token}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Add &lt;code&gt;https://&amp;lt;project&amp;gt;.pages.dev&lt;/code&gt; to Shapio&amp;#39;s &lt;code&gt;CORS_ORIGINS&lt;/code&gt; and restart Shapio after changing it. The entry&amp;#39;s Preview opens the draft beside the document; clicking its title or body focuses that field.&lt;/p&gt;&lt;h2 id=&quot;check-it-worked&quot;&gt;Check it worked&lt;/h2&gt;&lt;p&gt;Publish something. The connection&amp;#39;s run should move through &lt;code&gt;triggered → building → deployed&lt;/code&gt;, with Cloudflare&amp;#39;s build log link and the snapshot it was started for. Open the site and check the change.&lt;/p&gt;&lt;p&gt;A Pages deploy hook cannot pass parameters, so the build pins the snapshot current when it starts.&lt;/p&gt;&lt;h2 id=&quot;when-it-goes-wrong&quot;&gt;When it goes wrong&lt;/h2&gt;&lt;p&gt;If a build is already queued for the branch, Cloudflare answers the hook with HTTP 304. Shapio follows that queued build and reports its result.&lt;/p&gt;&lt;p&gt;A provider that stops answering shows &lt;code&gt;unknown&lt;/code&gt;. Shapio does not invent completion status.&lt;/p&gt;&lt;p&gt;A wrong &lt;code&gt;SHAPIO_DELIVERY_TOKEN&lt;/code&gt; makes the build fail reading content with 401. Shapio shows &lt;code&gt;failed&lt;/code&gt;, the reason and the log link, while the content stays published. Restore the right token and Retry the run. Check that it deploys and the site shows the change.&lt;/p&gt;</content:encoded><category>Guide</category></item><item><title>Schema as code: pull your content model into git and apply it live</title><link>https://shapio.dev/blog/schema-as-code-pull-commit-apply/</link><guid isPermaLink="true">https://shapio.dev/blog/schema-as-code-pull-commit-apply/</guid><description>You will have canonical schema files and their lock file committed in git, ready to apply to production with a per-model version guard.</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Shapio syncs the models in an instance with JSON files so you can pull, edit and apply your content model. You can review changes in git while every environment, including production, remains editable in the admin by default (a read-only lock exists, and it is opt-in).&lt;/p&gt;&lt;h2 id=&quot;before-you-start&quot;&gt;Before you start&lt;/h2&gt;&lt;p&gt;You need an instance and an admin API token from Settings → API tokens. All &lt;code&gt;shapio schema&lt;/code&gt; commands talk to the instance over HTTP, never to its database. Supply &lt;code&gt;--url&lt;/code&gt; and &lt;code&gt;--token&lt;/code&gt;, or set &lt;code&gt;SHAPIO_URL&lt;/code&gt; and &lt;code&gt;SHAPIO_TOKEN&lt;/code&gt;. Run the commands in your Shapio project (the one &lt;code&gt;npm install @shapio/cms&lt;/code&gt; or &lt;code&gt;create-shapio&lt;/code&gt; set up), where &lt;code&gt;npx shapio&lt;/code&gt; finds the CLI; anywhere else, use &lt;code&gt;npx -p @shapio/cms shapio&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Choose a local or staging instance for modelling. Schema sync moves models only. To move or back up content, use &lt;code&gt;shapio export&lt;/code&gt; and &lt;code&gt;shapio import&lt;/code&gt;.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Git mirrors production; it never locks it.&lt;/p&gt;&lt;/blockquote&gt;&lt;h2 id=&quot;step-1-pull-the-schema&quot;&gt;Step 1: Pull the schema&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export SHAPIO_URL=https://cms.example.com SHAPIO_TOKEN=shp_…
npx shapio schema pull&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pull writes one canonical JSON file per model and component, with sorted keys, stable IDs and every default spelled out. It also writes &lt;code&gt;.shapio/schema-lock.json&lt;/code&gt;, which records each definition&amp;#39;s version, hash and site, so commit both the schema files and the lock file.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Pulled 8 definition(s) of site &amp;quot;default&amp;quot; at schema version 12 into schema&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;step-2-check-the-files-and-site&quot;&gt;Step 2: Check the files and site&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;schema/
  models/                 shared with all sites
  components/
  sites/
    blog/models/          the blog site&amp;#39;s own
    shop/models/          the shop site&amp;#39;s own
.shapio/schema-lock.json  every definition&amp;#39;s version, hash and site, and the sites the tree covers&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Shared definitions go in &lt;code&gt;schema/models/&amp;lt;apiKey&amp;gt;.json&lt;/code&gt; and &lt;code&gt;schema/components/&amp;lt;apiKey&amp;gt;.json&lt;/code&gt;, while a site&amp;#39;s own definitions go in &lt;code&gt;schema/sites/&amp;lt;siteKey&amp;gt;/models/&lt;/code&gt; and &lt;code&gt;schema/sites/&amp;lt;siteKey&amp;gt;/components/&lt;/code&gt;. Commands work on one site&amp;#39;s view: &lt;code&gt;--site &amp;lt;key&amp;gt;&lt;/code&gt; or &lt;code&gt;SHAPIO_SITE&lt;/code&gt; selects it, otherwise they use the token&amp;#39;s site, then the primary site.&lt;/p&gt;&lt;h2 id=&quot;step-3-edit-and-review-in-git&quot;&gt;Step 3: Edit and review in git&lt;/h2&gt;&lt;p&gt;Edit models in your local or staging admin. In the builder, Review in a change set queues the edit and Ship now ships it at once. Then pull, commit and open a pull request, or edit the JSON files directly. A new file may omit IDs: the instance assigns them on apply and rewrites the file in canonical form.&lt;/p&gt;&lt;h2 id=&quot;step-4-preview-and-apply-to-production&quot;&gt;Step 4: Preview and apply to production&lt;/h2&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx shapio schema diff     # what apply would do; changes nothing
npx shapio schema apply&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;With &lt;code&gt;SHAPIO_URL&lt;/code&gt; and &lt;code&gt;SHAPIO_TOKEN&lt;/code&gt; pointing to production, diff previews the changes and apply runs the same change planner as the admin, with checks, background index builds and atomic activation, without a restart. Apply waits for activation, then updates the lock file and applied files; &lt;code&gt;--no-wait&lt;/code&gt; returns at once, while &lt;code&gt;--wait-timeout &amp;lt;seconds&amp;gt;&lt;/code&gt; changes the default 600-second wait.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;model article: update
    + field subtitle
Applied 1 definition(s) at schema version 13.&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;step-5-choose-how-schema-and-content-ship&quot;&gt;Step 5: Choose how schema and content ship&lt;/h2&gt;&lt;p&gt;CLI apply activates each changed definition directly and does not create a change set. To ship schema and content together, use the admin instead. Develop → Schema saves edited files as drafts in a change set, after the same three-way check, and shipping that set publishes the schema and its content items as one snapshot. In the builder, Ship now ships a single edit as a one-item change set.&lt;/p&gt;&lt;h2 id=&quot;check-it-worked&quot;&gt;Check it worked&lt;/h2&gt;&lt;p&gt;Run &lt;code&gt;npx shapio schema diff&lt;/code&gt; again: it reports nothing to apply. Commit the updated lock file.&lt;/p&gt;&lt;h2 id=&quot;how-apply-decides&quot;&gt;How apply decides&lt;/h2&gt;&lt;p&gt;Apply compares the base in the lock file, your file and the target&amp;#39;s active definition for each model. If your file is unchanged, it skips the model and keeps live changes made since your pull. If only your file changed, it applies with the target&amp;#39;s current version as a guard. If both changed, it refuses and shows both sides. A file identical to the target is already applied and skipped.&lt;/p&gt;&lt;p&gt;The reverse workflow is valid too: edit production, then pull and reconcile. For automatic write-back, add a GitHub connection under Publishing → Deployments. After schema changes, it commits the canonical files and lock file, or opens a pull request from &lt;code&gt;shapio/schema-sync&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Check Develop → Snapshots for the schema version recorded with a snapshot. Metadata-only changes do not take a snapshot; conversions of stored values do. Older snapshots use the current schema, and restoring content does not roll back the schema.&lt;/p&gt;&lt;h2 id=&quot;when-it-goes-wrong&quot;&gt;When it goes wrong&lt;/h2&gt;&lt;p&gt;A conflict refuses the whole apply and writes nothing. The refusal looks like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SCHEMA_SYNC_CONFLICT: The target schema changed since your last pull for some definitions you also changed. Pull, reconcile and apply again.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Commit your files, then run:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx shapio schema pull --force&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This overwrites files with the target&amp;#39;s definitions and records the new base. Use &lt;code&gt;git diff&lt;/code&gt; to bring your changes back on top, commit and apply again. Pull also refuses unapplied local edits unless you use &lt;code&gt;--force&lt;/code&gt;. After upgrading Shapio, pull again: a new version can add default properties to definitions, which changes their hashes.&lt;/p&gt;&lt;p&gt;Breaking changes need &lt;code&gt;--allow-breaking&lt;/code&gt;; destructive conversions need &lt;code&gt;--allow-destructive&lt;/code&gt;. Deleting a file does nothing on the target without &lt;code&gt;--prune&lt;/code&gt;, which still refuses deletion if the target definition changed.&lt;/p&gt;&lt;p&gt;For several sites, &lt;code&gt;LOCK_SITE_MISMATCH&lt;/code&gt; means you must pull the selected site first. &lt;code&gt;SCOPE_MISMATCH&lt;/code&gt; means the file&amp;#39;s folder disagrees with the instance: pull its layout. Changed shared definitions require schema permission on every site; &lt;code&gt;FORBIDDEN_SCOPE&lt;/code&gt; lists refused definitions and nothing applies. Check that the selected site&amp;#39;s tree and shared-definition permissions match the target before applying again.&lt;/p&gt;</content:encoded><category>Guide</category></item><item><title>What&apos;s left of the JAMstack</title><link>https://shapio.dev/blog/whats-left-of-the-jamstack/</link><guid isPermaLink="true">https://shapio.dev/blog/whats-left-of-the-jamstack/</guid><description>JAMstack is dead.</description><pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;what-the-word-meant&quot;&gt;What the word meant&lt;/h2&gt;&lt;p&gt;JAMstack stood for JavaScript, APIs and Markup. Netlify&amp;#39;s founders coined it around 2016. The proposal was concrete: pre-render a site to static files, serve those files from a CDN, and use browser JavaScript and third-party APIs for anything dynamic.&lt;/p&gt;&lt;p&gt;Gatsby, Hugo, Jekyll, Eleventy and early Next.js static export belonged in that picture. Netlify and Vercel built businesses on it. Content came from Markdown files in the repository or from an API, and the API side is where headless CMSs grew up.&lt;/p&gt;&lt;h2 id=&quot;what-it-got-right&quot;&gt;What it got right&lt;/h2&gt;&lt;p&gt;Static files are fast, cheap and hard to hack. A CDN is a better front door than a PHP box. Those remain good reasons to build a site this way.&lt;/p&gt;&lt;p&gt;The build also gave you a reproducible artifact. You could pin it and roll it back. I put more value on that property than on whether a framework still fits an old acronym.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;A build is a reproducible artifact.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;The content API mattered just as much. Content could be the source for the site without being a database joined to templates.&lt;/p&gt;&lt;p&gt;Choosing an API for content does not settle whether every page should be static. It gives you a source to work from. Content delivery and page rendering are separate decisions.&lt;/p&gt;&lt;h2 id=&quot;how-it-went-wrong&quot;&gt;How it went wrong&lt;/h2&gt;&lt;p&gt;Build times grew with content. A ten-thousand-page site rebuilding for a typo is a concrete problem, whatever you call the architecture. A tidy deployment model doesn&amp;#39;t excuse that cost.&lt;/p&gt;&lt;p&gt;Every dynamic need became a function or a client-side fetch. Incremental regeneration and on-demand rendering were bolted on. Those additions made the original definition less useful as a guide to what a site actually does.&lt;/p&gt;&lt;p&gt;By now, every major framework renders on the server again. Next.js has the app router, Astro has server islands and on-demand pages, and SvelteKit and React Router (formerly Remix) are part of that server-rendered picture. Netlify itself stopped leading with the word JAMstack.&lt;/p&gt;&lt;p&gt;The term is dead; the useful parts have become standard practice. Describe which pages are built ahead of time, which need server rendering, and where their content comes from. That tells me more than the label does.&lt;/p&gt;&lt;h2 id=&quot;what-s-left&quot;&gt;What&amp;#39;s left&lt;/h2&gt;&lt;p&gt;Keep the build as an artifact you can pin and roll back. Keep static output wherever content changes slower than requests arrive. A long build is a reason to examine the build, not to discard static files everywhere else.&lt;/p&gt;&lt;p&gt;Keep a content API as the source. I don&amp;#39;t want the choice of templates to dictate where content lives. That preference doesn&amp;#39;t mean every site needs a headless CMS.&lt;/p&gt;&lt;p&gt;Rebuilding on publish is the other part worth keeping, with one correction. Publication is the point at which the content changes for readers, so that is the moment to produce pages. That part the old pattern got right.&lt;/p&gt;&lt;p&gt;What it got wrong was the scope. A typo rebuilt the whole site, and that is what people walked away from. That part is fixable now. Astro can restore every page whose content and code did not change and render only the rest, and a cached server-rendered site, like Next.js with cache tags, can expire just the pages a publish touched.&lt;/p&gt;&lt;p&gt;The dynamic parts can still be dynamic. Another part needing server rendering is no reason to abandon static output for content that suits it. Make that decision around the content and the request.&lt;/p&gt;&lt;h2 id=&quot;where-shapio-fits&quot;&gt;Where Shapio fits&lt;/h2&gt;&lt;p&gt;Shapio is built for the parts of this that held up. A build pins one publication snapshot (&lt;code&gt;?snapshot=N&lt;/code&gt;), so every page in it shows the same moment of content and schema. With a deployment connection, a publish fires the deploy hook and Shapio follows the build until it finishes. The site you are reading is built that way.&lt;/p&gt;&lt;p&gt;The scope problem is handled in the starters. The Astro starter keys every page on the content it renders, so a publish re-renders the pages that changed and restores the rest from the previous build, as long as the host keeps its build cache. The Next.js starter caches its reads by tag, and a publish expires only the tags of the content that changed.&lt;/p&gt;&lt;p&gt;The CMS itself never needs a deploy. Content types are data in the database: change one in the admin and the REST and GraphQL APIs serve the new field on the next request. Showing that field on the site is a change to the site&amp;#39;s code, which ships with the site&amp;#39;s next deploy.&lt;/p&gt;&lt;p&gt;For the parts that must be dynamic, the same content is on the API, and in a Next.js app &lt;code&gt;@shapio/local&lt;/code&gt; serves it in-process. Build the static pages on publish, and render on request only what needs it.&lt;/p&gt;</content:encoded><category>Opinion</category></item><item><title>Why headless</title><link>https://shapio.dev/blog/why-headless/</link><guid isPermaLink="true">https://shapio.dev/blog/why-headless/</guid><description>I want content to be data, the front end to be mine, and the schema to be a contract both sides can work against.</description><pubDate>Wed, 30 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;what-the-head-was&quot;&gt;What the head was&lt;/h2&gt;&lt;p&gt;A headless CMS stores and serves content through an API and leaves rendering to whatever front end you build. The head was the theme and the templates, the part that turned stored content into a page. Remove that part and you have content available to a separate application.&lt;/p&gt;&lt;h2 id=&quot;content-is-data&quot;&gt;Content is data&lt;/h2&gt;&lt;p&gt;My first reason is that content is data. An article has a title, a body, and whatever other fields its model defines. A page is a rendering of that article. Treating the article as data gives you a better starting point than treating its current page as the thing you are storing.&lt;/p&gt;&lt;p&gt;The same source can feed a site, an app, a feed, or a script. You do not have to enter the content again for each destination. Each consumer can take the fields it needs and decide what to do with them.&lt;/p&gt;&lt;p&gt;There is still work in each consumer. An API does not design your app or write your feed. That work belongs with the consumer, where the requirements for its output are clear.&lt;/p&gt;&lt;h2 id=&quot;the-front-end-is-yours&quot;&gt;The front end is yours&lt;/h2&gt;&lt;p&gt;My second reason is control over rendering. I want to choose the framework, the host, and how the front end produces its pages. I do not want a theme system making those decisions for me.&lt;/p&gt;&lt;p&gt;If you have worked with WordPress, you know what a theme is responsible for. Headless moves that responsibility into the front end you build. You own the templates because they are part of your application.&lt;/p&gt;&lt;p&gt;It also means the choice is yours to maintain. Choosing your own front end includes building it and taking responsibility for it. That is a reasonable trade when a team actually wants that control.&lt;/p&gt;&lt;h2 id=&quot;the-schema-is-the-contract&quot;&gt;The schema is the contract&lt;/h2&gt;&lt;p&gt;My third reason is the schema. The content model defines the API, and types follow from that model. The front end and the content team can agree on a shape instead of agreeing only on a page.&lt;/p&gt;&lt;p&gt;That changes what I want to discuss when modelling content. We need to agree on which fields belong to a type and which parts of the content should be shared. Those decisions describe the data the front end will receive.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;The schema is the contract.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;The front end depends on a shape, and changes to that shape need attention. Moving rendering out of the CMS does not remove the need for agreement.&lt;/p&gt;&lt;h2 id=&quot;a-service-with-an-api&quot;&gt;A service with an API&lt;/h2&gt;&lt;p&gt;A CMS should behave like any other service with an API. Your pages do not run through the CMS&amp;#39;s own code, whether that is WordPress&amp;#39;s PHP or anything else, and the site is not assembled from plugins that each bring their own data shapes, update cycle and attack surface.&lt;/p&gt;&lt;p&gt;The CMS stores and serves content, and the front end renders it. When something breaks, it is clear which side broke.&lt;/p&gt;&lt;h2 id=&quot;when-i-would-keep-the-head&quot;&gt;When I would keep the head&lt;/h2&gt;&lt;p&gt;For a brochure site with an editor who wants to drag things around a page and never touch code, and no developer building the site, I would keep the head. Building a separate front end adds a job that editor did not ask for. I would choose a tool that fits the way they want to work.&lt;/p&gt;&lt;p&gt;For a small shop where the storefront and the catalogue are one product and nobody wants to build a front end, Shopify or WooCommerce handles it end to end. Headless pays off when the catalogue feeds more than one storefront, which is why a headless e-commerce layer is on Shapio&amp;#39;s roadmap.&lt;/p&gt;&lt;p&gt;The same goes for any team with no developer. Headless gives you a front end to build. If nobody wants to build one, use the thing with the head.&lt;/p&gt;&lt;h2 id=&quot;changing-the-contract&quot;&gt;Changing the contract&lt;/h2&gt;&lt;p&gt;The usual headless cost arrives when the model changes. Strapi disables its content-type builder in production, and Payload defines fields in TypeScript. Model changes can mean code, commits, and a deploy.&lt;/p&gt;&lt;p&gt;Shapio&amp;#39;s position is that the model should be data too. Change a type in the admin while it runs in production, and the REST and GraphQL APIs serve the new field on the next request. There is no rebuild, restart, or deploy for that model change.&lt;/p&gt;&lt;p&gt;Schema and content ship together in a change set, recorded as a publication snapshot that a build can pin. Every page in the build then sees one consistent moment. That is the contract I want to build against.&lt;/p&gt;</content:encoded><category>Opinion</category></item></channel></rss>