All guides
Documentation

Developer Guide

Everything you need to integrate SpaceBlock into a React + Vite + TypeScript app — from the SDK loader to CDN cache invalidation.

1

Overview & Architecture

SpaceBlock is a headless CMS with a click-to-edit Visual Editor. Your site stays a normal React SPA; the SDK loads inside the editor's iframe and lets editors edit the very page they're looking at. Content is read at runtime through a public REST API.

There are two complementary editing models — most projects use both:

  • Content Blocks — individual editable values (data-cms-id) bound to fixed UI in your code. Best for a tagline, a footer line, a static banner.
  • Pages & Elements — composable pages built from reorderable template instances. Editors add, remove, reorder, and swap sections without code changes. This is how the homepage and every marketing page work.

Recommended project structure

src/
  components/
    layout/
      Layout.tsx        # Navbar + children + Footer + TemplateRegistry
      Navbar.tsx        # CMS-driven nav with live preview
      Footer.tsx        # CMS-driven footer with live preview
    sections/
      HeroBanner.tsx    # one render component per template
      RichText.tsx
  pages/
    DynamicPage.tsx     # catch-all CMS page renderer (also handles "/")
    BlogListPage.tsx
    BlogPostPage.tsx
    BlogPreviewPage.tsx
  lib/
    api.ts              # all SpaceBlock fetch helpers
    types.ts            # TypeScript interfaces
  App.tsx               # SDK init + routing (wrapped in Layout)
index.html              # SDK script loader

Full reference

This guide is the complete, navigable overview. The deepest reference (every code file, every edge case) lives in the integration-guide/ folder in the repo — each chapter below maps to one markdown file there.

2

Setup & SDK

1. SDK loader in index.html

The SDK must load from the same origin as the CMS so the editor's iframe messaging works. Vite substitutes %VITE_SPACEBLOCK_API_BASE% at build time. Never branch the SDK URL on window.location.hostname — your dev server is on localhost but the CMS lives wherever the env points.

<script>
  (function () {
    var base = '%VITE_SPACEBLOCK_API_BASE%';
    if (!base || base.indexOf('%VITE_') === 0) {
      console.warn('[SpaceBlock] VITE_SPACEBLOCK_API_BASE not set; SDK will not load.');
      return;
    }
    var script = document.createElement('script');
    script.src = base.replace(/\/$/, '') + '/spaceblock-sdk.js';
    script.defer = true;
    document.head.appendChild(script);
  })();
</script>

2. Environment variables (.env.local)

# Root URL only — do NOT include /api/public
VITE_SPACEBLOCK_API_BASE=https://www.spaceblock.app
VITE_SPACEBLOCK_API_KEY=your-api-key
VITE_SPACEBLOCK_PROJECT_ID=your-project-id

3. Initialise the SDK once, after React mounts

A module-level flag survives React StrictMode's double-invoke. Use autoLoad: false and call load() after two animation frames so the CMS attributes are in the DOM before the SDK scans them.

let sdkInitialised = false

export default function App() {
  useEffect(() => {
    if (sdkInitialised) return
    sdkInitialised = true
    function init() {
      if (window.SpaceBlock) {
        window.SpaceBlock.init({
          apiKey: import.meta.env.VITE_SPACEBLOCK_API_KEY,
          projectId: import.meta.env.VITE_SPACEBLOCK_PROJECT_ID,
          autoLoad: false,        // SPAs render first, then load()
          cacheEnabled: true,
          hideUntilLoaded: false, // React provides fallback content
        })
        requestAnimationFrame(() => requestAnimationFrame(() =>
          setTimeout(() => window.SpaceBlock?.load(), 200)
        ))
      } else {
        setTimeout(init, 50)
      }
    }
    init()
  }, [])
  return <Layout><Routes>{/* ... */}</Routes></Layout>
}

Configuration options

OptionDefaultDescription
apiKeyProject API key (required)
projectIdProject ID (required)
autoLoadfalseAuto-load on init. Use false in SPAs
cacheEnabledtrueLocal content caching
hideUntilLoadedtrueSet false when React provides fallbacks

Finally, in Project Settings → Domains, set your production domain and dev domain (e.g. http://localhost:5173). The editor loads your site in an iframe using the matching domain.

3

Visual Editor

The editor loads your site in an iframe, detects edit mode, and lets editors click any element carrying data-cms-id to edit it inline. Changes save to SpaceBlock and sync back to the page.

<h1 data-cms-id="hero-title">Welcome</h1>
<p data-cms-id="hero-desc" data-cms-type="richtext">Editable rich text.</p>
<img data-cms-id="hero-image" data-cms-type="image" src="/placeholder.jpg" alt="" />
<a data-cms-id="cta" data-cms-type="url" href="/contact">Contact Us</a>

Field types (data-cms-type)

text (default), richtext (TipTap), image, url, select (with data-cms-options="a,b,c"), toggle, rating, collection-select.

Live-preview messages

The editor talks to your site via postMessage. Listen for these to update instantly while editing:

MessageMeaning
CMS_UPDATEA single field changed — patch it in state
GLOBAL_ELEMENT_UPDATENavbar/footer/CTA changed
CMS_INSERT_ELEMENTNew element inserted — refetch the page
CMS_REFRESH_AFTER_DELETEElement deleted — refetch the page

Without the listener

Navbar/footer edits only show after a manual refresh, inserted elements appear twice, and deleted elements leave ghost nodes. Wiring it up (see Chapter 6) makes editing feel live.

4

Content Blocks

Content Blocks are the simplest editable unit: a unique data-cms-id on an element in code. Use them for fixed UI values; use Pages & Elements (Chapter 6) for composable pages.

import { fetchContent } from '@/lib/api'

// Returns a Record<string, string> keyed by contentId
const content = await fetchContent()
content['hero-title']        // "Welcome to Our Site"
content['hero-description']  // "<p>Rich text</p>"

Name IDs hierarchically — page-section-element (e.g. home-hero-title, footer-copyright). Every ID must be unique, and always provide fallback content in your markup.

5

Templates

A template declares a schema (its editable fields) and is paired with a section component that renders live instances. The two are decoupled: the schema lives in TemplateRegistry, the renderer in src/components/sections/. They agree only on field-suffix names.

TemplateRegistry — hardcoded schema, editor-only

function TemplateRegistry() {
  const inIframe = typeof window !== 'undefined' && window.self !== window.top
  if (!inIframe) return null   // editor-only; never ships to visitors
  return (
    <div id="cms-templates" aria-hidden className="absolute w-px h-px overflow-hidden [clip:rect(0,0,0,0)]">
      <div data-cms-insertable="hero-banner" data-cms-name="Hero Banner" data-cms-category="hero">
        <span data-cms-id="title" data-cms-type="text" />
        <span data-cms-id="description" data-cms-type="richtext" />
        <img data-cms-id="background" data-cms-type="image" alt="" />
      </div>
      {/* one block per template */}
    </div>
  )
}

Section component — render-mode only

The renderer takes an optional elementId and builds full field keys via a fid() helper. It never emits data-cms-insertable — that's the registry's job.

export function HeroBanner({ elementId, title = '', description = '', background }) {
  const fid = (s) => (elementId ? `${elementId}-${s}` : s)
  return (
    <section data-cms-element-id={elementId}>
      <h1 data-cms-id={fid('title')}>{title}</h1>
      <p data-cms-id={fid('description')} data-cms-type="richtext">{description}</p>
    </section>
  )
}

Adding a template — checklist

  1. Register the schema block in TemplateRegistry.
  2. Create the render-mode component in sections/.
  3. Re-export it from sections/index.ts.
  4. Add a detection branch in renderElement() (Chapter 6).

Use data-cms-show-when="variant:dark" on a field to show it only when a select/toggle has a given value.

6

Pages & Elements

A Page is a CMS-managed URL; its body is a list of reorderable PageElements, each instanced from a template with an elementId, a zone, an order, and a content map. DynamicPage is the single component that renders every CMS page — including / as slug home. Don't write a separate HomePage.

Fetching & the missing-page state

fetchPage(slug) returns null on 404 (page not created yet — a normal onboarding state) and throws on other errors. Render a dev-only "create this page" panel when null.

Triple-detection

The CMS doesn't always populate templateName, so match on three signals — and pick a content-key suffix that is unique to the template:

if (
  templateName === 'Hero Banner'                       // 1. exact name
  || elementId.includes('hero-banner')                 // 2. elementId prefix
  || contentKeys.some(k => k.endsWith('-background'))  // 3. unique field key
) {
  return <HeroBanner elementId={elementId} title={find('title')} background={image('background')} />
}

find() / findImage() helpers

Look up fields by suffix (not full key) and unwrap the { url, text } object form the CMS sometimes returns. Always use findImage() for image fields (it accepts multiple suffixes for renamed fields).

Three live-preview channels

  1. CMS_UPDATE postMessage — patch one field in state, no refetch.
  2. MutationObserver on <img src> — some image pickers mutate the DOM directly.
  3. Structural events — insert/delete invalidate the page cache and refetch.

Draft pages & CMS preview

Forward cms-preview or drafts render empty

Unpublished pages return 404 publicly. The editor appends ?cms-preview=true, but the cross-origin referrer is stripped — so your publicUrl() builder must forward that flag on every fetch. The API also accepts an X-CMS-Preview: true header.

Every fetch must cache-bust while the editor is open: _t=Date.now() query param + cache: 'no-store'. Mark the render container data-cms-zone="main" and sort elements by order before rendering.

7

Slot-Based Templates

Slot templates manage a numbered list of repeating items (project showcases, team grids) that editors can add, remove, and drag to reorder. Each item occupies a numbered slot with a shared prefix; a per-slot order field stores its position.

project-1-image    (image)
project-1-client   (text)   # doubles as the drag-list row label
project-1-url      (url)
project-1-order    (text)   # written automatically by drag-and-drop
project-1-variant  (select) # square | tall | wide

Render all slots (e.g. SLOT_COUNT = 60) as hidden fields so the CMS can discover them, but only display populated ones. Sort by order ?? slot. The editor auto-detects the project-N-* pattern (when ≥2 slot numbers exist) and shows a draggable accordion — no extra CMS config. Deleting a slot clears its fields (keeping the schema so it's reusable). The same shape works for any prefix (member-N-, testimonial-N-).

Hidden image slots = phantom fetches

See Chapter 13 — gate hidden <img> slot markers to the editor iframe so public visitors don't download all 60.

8

Blog

A full blog system: posts with tags, authors, featured images, draft/publish, SEO, and block-based content. Routes: /blog (list), /blog/:slug (post), /blog/preview/:token (preview).

Block types

richtext, heading, image, video, audio, quote, table, download, cta, divider, two-column-text, three-column-text, intro-summary, executive-summary, author-details, acknowledgements, footnotes, subblog.

Render each block with a BlogContentBlock component (a switch on block.type). Posts may carry block arrays or a raw HTML string — handle both. Use dateWritten ?? createdAt for display.

Preview links

Editors generate a secure token to share unpublished posts. Fetch via /api/public/blog/preview/{token} — no API key needed, the token is the auth. Show an amber "Preview Mode" banner when isPreview is true.

9

Collections

Collections are groups of structured items — team, products, testimonials, portfolio, FAQs. Each item has built-in fields (title, slug, description, image, tags, links) plus a free-form data object for custom fields.

const { collection, items } = await fetchCollection('team')
items.sort((a, b) => a.order - b.order)   // always respect order
items.filter(i => i.tags.includes('Featured'))

Public endpoints: /api/public/collections/{slug} and /api/public/collections/{slug}/{itemSlug} (both need projectId + apiKey). Reference a collection inside a template with a collection-select field.

10

Media Library

Centralised image/file management with Bunny.net CDN delivery. Configure it in Project Settings with your Storage Zone name, Storage API key, and CDN (pull-zone) URL. Files are organised per project: your-zone.b-cdn.net/project-id/file.jpg.

Editors upload via the Media tab or the image picker in the Visual Editor. In code, just point an <img> at the CDN URL and add loading="lazy", srcset/sizes for responsive delivery. Prefer WebP and always set alt text.

11

Global Elements

Navbar, footer, and CTA are project singletons available on every page. Your app only reads them (via fetchGlobalElement) and listens for GLOBAL_ELEMENT_UPDATE to live-update.

Don't auto-register from the browser

CORS blocks PUT from browser origins, so a browser-side seed always fails. Create global elements in the dashboard, or seed server-side with npm run seed:globals (idempotent — skips existing elementIds).

Each component should bake in sensible defaults and merge partial CMS data ({ ...DEFAULT, ...data }) so it renders before any fetch resolves. Supported types with dedicated editors: navbar, footer, cta (others fall back to a raw JSON editor).

12

SEO & Social Metadata

Social crawlers don't run JavaScript, so meta tags injected by your SPA at runtime are invisible to them. SpaceBlock solves this by serving your index.html shell with per-slug tags injected server-side.

Pages and posts expose an optional seo object (title, description, image, canonical, type, noindex) that falls back through page/post fields to project-wide defaults. Authoring happens per-post (editor sidebar), per-page (page Settings → SEO & Social), and project-wide (Project → SEO).

Render endpoint + host rewrite

GET /api/public/render/{projectId}/{slug}        # page by slug
GET /api/public/render/{projectId}/blog/{slug}   # blog post
GET /api/public/render/{projectId}               # home

Route the document request (not static assets) to the render endpoint via a host rewrite (Vercel / Netlify / Cloudflare / nginx). It replaces the <!-- seo:start -->…<!-- seo:end --> block with resolved tags and returns a true 404 + noindex for unknown slugs. Keep /index.html directly reachable (the endpoint fetches it as the shell).

13

Editor-Only DOM

Any element that exists only for the editor to attach to — typically a hidden <img data-cms-id … className="hidden"> marker — still gets fetched by the browser's preload scanner. display:none does not stop the download. Every public visitor pays for every marker.

Two fixes

  1. Preferred — put data-cms-id on the visible element so there's no hidden duplicate.
  2. Gate by iframe presence when there's no visible counterpart:
{typeof window !== 'undefined' && window.self !== window.top && (
  <img data-cms-id="hero-desktop" data-cms-type="image" src={url} className="hidden" />
)}

// Audit the whole project:
//   grep -rn 'data-cms-type="image"' src/
// Every result that isn't the visible image (or gated) is a phantom fetch.

autoLoad: true sites

This applies to SPAs that render their own content. If you use SDK autoLoad: true, the markers are where content actually lives — don't gate them.

14

CDN Cache & Webhooks

To make your site fast without serving stale content, SpaceBlock notifies your host to purge its cache when an editor publishes. CMS API responses come from an app-level Redis cache (write-through invalidated on every edit) plus ETag/304 revalidation; your own CDN is purged via a signed webhook.

The purge webhook

Configure a Purge webhook URL and secret in Project Settings → CDN cache invalidation. On publish, the CMS sends an HMAC-SHA256-signed POST to your endpoint:

POST /api/revalidate-cdn
X-ContentLite-Signature: sha256=<hmac of raw body>
X-ContentLite-Event: content.published

{ "projectId": "...", "event": "content.published",
  "paths": ["/fast-grants", "/"], "timestamp": 1717920000000 }

Your receiver verifies the signature (timing-safe), rejects events >5 min old (replay protection), then purges the listed paths (["*"] means full-site, used for global/SEO changes). Reference receivers exist for Vercel, Next.js (revalidatePath), Cloudflare, and Netlify. Set CDN_PURGE_SECRET on your site to match. The Test webhook button verifies reachability + signature.

Backstop

If the webhook fails, the stale-while-revalidate window self-heals — content still updates within the TTL. Editors mid-flow always see fresh content (preview mode bypasses cache).

15

API Reference

Auth

Public endpoints take your API key via ?apiKey= or the x-api-key header (recommended). Dashboard endpoints use a NextAuth session.

Public endpoints

GET /api/public/content?apiKey=...                      # content blocks
GET /api/public/blog?projectId=...&apiKey=...&tag=&limit=&offset=
GET /api/public/blog/{slug}?apiKey=...
GET /api/public/blog/preview/{token}                    # no apiKey needed
GET /api/public/collections/{slug}?projectId=...&apiKey=...
GET /api/public/collections/{slug}/{itemSlug}?...
GET /api/public/pages/{projectId}/list
GET /api/public/pages/{projectId}/{slug}                # 404 if draft (use cms-preview)
GET /api/public/global-elements/{projectId}?apiKey=...&type=
GET /api/public/render/{projectId}/{slug}               # SEO HTML (text/html)

Caching, ETags & preview

Responses carry an ETag; send If-None-Match to get a cheap 304. Add ?cms-preview=true (or the X-CMS-Preview header) to bypass cache and resolve drafts. Status codes follow REST conventions (200 / 304 / 400 / 401 / 404 / 500).

Status & limits

Public API is rate-limited to ~1000 req/min per key and supports CORS from any origin. Dashboard (session) endpoints cover full CRUD for projects, media, blog, collections, pages, elements (incl. reorder), and global elements.

Deeper reference

Full request/response examples for every endpoint live in integration-guide/09-api-reference.md.