Everything you need to integrate SpaceBlock into a React + Vite + TypeScript app — from the SDK loader to CDN cache invalidation.
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:
data-cms-id) bound to fixed UI in your code. Best for a tagline, a footer line, a static banner.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 loaderFull 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.
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-id3. 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
| Option | Default | Description |
|---|---|---|
apiKey | — | Project API key (required) |
projectId | — | Project ID (required) |
autoLoad | false | Auto-load on init. Use false in SPAs |
cacheEnabled | true | Local content caching |
hideUntilLoaded | true | Set 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.
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:
| Message | Meaning |
|---|---|
CMS_UPDATE | A single field changed — patch it in state |
GLOBAL_ELEMENT_UPDATE | Navbar/footer/CTA changed |
CMS_INSERT_ELEMENT | New element inserted — refetch the page |
CMS_REFRESH_AFTER_DELETE | Element 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.
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.
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
TemplateRegistry.sections/.sections/index.ts.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.
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
<img src> — some image pickers mutate the DOM directly.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.
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 | wideRender 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.
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.
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.
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.
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).
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} # homeRoute 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).
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
data-cms-id on the visible element so there's no hidden duplicate.{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.
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).
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.