A framework-like CMS that makes content management effortless for your clients.
Edit content directly on the live website! Click any element to edit it
⨠Click-to-Edit
Hover and click elements directly on your website
ā” Instant Preview
See changes as you type, no reload needed
š Zero Setup
Works automatically with existing SDK
šÆ Default View
Opens automatically when accessing a project
Create a project in the dashboard to get your API Key and Project ID.
<!DOCTYPE html>
<html>
<head>
<title>My Site</title>
<!-- ā
Load SDK in head with defer for best performance -->
<script src="https://spaceblock.vercel.app/spaceblock-sdk.js" defer></script>
</head>
<body>
<div id="root"></div>
<!-- Your app scripts -->
<script type="module" src="/src/main.tsx"></script>
<!-- Initialize SDK -->
<script>
window.addEventListener('DOMContentLoaded', function() {
if (window.SpaceBlock) {
SpaceBlock.init({
apiKey: 'YOUR_API_KEY',
projectId: 'YOUR_PROJECT_ID',
autoLoad: true, // Automatic timing!
debug: true // Enable for development
});
}
});
</script>
</body>
</html><h1 data-cms-id="hero-title">Welcome to My Site</h1>
<p data-cms-id="hero-subtitle">Editable content</p>That's it! Your client can now edit this content from the dashboard using the Visual Editor!
Go to Project Settings and add your website domain:
http://localhost:3000 (for development)
or
https://your-site.com (for production)Done! The Visual Editor will now load your site and make all elements clickable.
The SDK automatically detects when it's running inside the CMS iframe and enables visual editing features. No configuration needed!
Uses MutationObserver to handle React re-renders. Elements stay clickable even after your app updates.
Visual Editor loads in 1-2 seconds with these optimizations:
defer attributeloading="lazy"ā
Load SDK in <head> with defer
ā
Add loading="lazy" to all images
ā
Use React 18 createRoot
ā Implement code splitting for routes
ā Add preconnect links for CMS domain
The CMS SDK detects insertable templates by scanning the current page's DOM for elements with data-cms-insertable attributes. Templates are only detected when their components are rendered on the page being viewed.
Add a hidden TemplateRegistry component to your Layout that registers all available templates on every page:
// components/TemplateRegistry.jsx
function TemplateRegistry() {
// Only render when inside CMS iframe (visual editor)
const isInCmsIframe = typeof window !== 'undefined' && window.self !== window.top;
if (!isInCmsIframe) return null;
return (
<div
aria-hidden="true"
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{/* Register all insertable templates here */}
<div data-cms-insertable="hero-section" />
<div data-cms-insertable="features-section" />
<div data-cms-insertable="contact-section" />
<div data-cms-insertable="collection-block" />
</div>
);
}// components/Layout.jsx
import TemplateRegistry from './TemplateRegistry';
export function Layout({ children }) {
return (
<div>
<Header />
<main>{children}</main>
<Footer />
<TemplateRegistry />
</div>
);
}aria-hidden="true" ensures accessibilitydata-cms-insertable="template-name"<div data-cms-insertable="template-name" /> to TemplateRegistrydata-cms-id(Required)Unique identifier for the content block.
<h1 data-cms-id="hero-title">My Title</h1>data-cms-page(Optional)Groups content by page. Default: "home"
<!-- Home page -->
<h1 data-cms-id="hero-title" data-cms-page="home">Home</h1>
<!-- About page -->
<h1 data-cms-id="hero-title" data-cms-page="about">About</h1>data-cms-section(Optional)Groups content into sections. Auto-inferred from cms-id if not specified.
<div data-cms-section="hero">
<h1 data-cms-id="hero-title">Title</h1>
<p data-cms-id="hero-subtitle">Subtitle</p>
</div>data-cms-component(Optional)Groups related elements into components.
<div data-cms-component="testimonial-card-1">
<p data-cms-id="testimonial-1-text" data-cms-component="testimonial-card-1">
Great product!
</p>
<h4 data-cms-id="testimonial-1-author" data-cms-component="testimonial-card-1">
John Doe
</h4>
</div>data-cms-order(Optional)Manually control display order. Default: auto-ordered by DOM position.
<h1 data-cms-id="hero-title" data-cms-order="1">Title</h1>
<footer data-cms-id="footer-text" data-cms-order="999">Footer</footer>Content is organized in a hierarchical tree view:
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Landing Page</title>
</head>
<body>
<!-- Hero Section -->
<header data-cms-section="hero">
<h1 data-cms-id="hero-title" data-cms-section="hero">
Welcome to Our Product
</h1>
<p data-cms-id="hero-subtitle" data-cms-section="hero">
The best solution for your needs
</p>
<a href="#" data-cms-id="hero-cta" data-cms-section="hero">
Get Started
</a>
</header>
<!-- Features Section -->
<section data-cms-section="features">
<h2 data-cms-id="features-title">Why Choose Us?</h2>
<div data-cms-component="feature-1">
<h3 data-cms-id="feature-1-title" data-cms-component="feature-1">
Fast Performance
</h3>
<p data-cms-id="feature-1-description" data-cms-component="feature-1">
Lightning-fast load times
</p>
</div>
<div data-cms-component="feature-2">
<h3 data-cms-id="feature-2-title" data-cms-component="feature-2">
Easy to Use
</h3>
<p data-cms-id="feature-2-description" data-cms-component="feature-2">
Intuitive interface
</p>
</div>
</section>
<!-- Footer -->
<footer data-cms-section="footer">
<p data-cms-id="footer-copyright">Ā© 2025 My Company</p>
</footer>
<!-- SpaceBlock SDK -->
<script src="https://spaceblock.vercel.app/spaceblock-sdk.js"></script>
<script>
SpaceBlock.init({
apiKey: 'YOUR_API_KEY',
projectId: 'YOUR_PROJECT_ID',
apiUrl: 'https://spaceblock.vercel.app/api/public/content',
autoDetect: true
});
window.addEventListener('load', () => {
SpaceBlock.load();
});
</script>
</body>
</html>Use semantic HTML structure
Organize your HTML in logical reading order (top to bottom)
Consistent naming patterns
Use kebab-case: feature-1-title, not feat1 or Feature1Title
Group related content with components
Use data-cms-component for cards, testimonials, etc.
Provide meaningful default content
Avoid placeholders like [Title Goes Here]
Use images and links
Just add data-cms-id to <img> and <a> tags - auto-detected!
Built-in optimization
Smart caching and zero layout shift - no flickering!
SpaceBlock offers three levels of optimization for different deployment scenarios.
Perfect for development and quick deployments. Content is cached in localStorage after first load.
// Enabled by default - zero config!
SpaceBlock.init({
apiKey: 'YOUR_API_KEY',
projectId: 'YOUR_PROJECT_ID',
autoDetect: true,
cacheEnabled: true, // Default: true
hideUntilLoaded: true // Default: true (prevents flash)
});3x faster first load! Content is embedded in HTML at build time. Zero network latency, zero flash.
Step 1: Download the build script
curl -O https://spaceblock.vercel.app/spaceblock-build.jsStep 2: Run before deploying
node spaceblock-build.js \
--apiKey YOUR_API_KEY \
--input index.html \
--output dist/index.htmlStep 3: Integrate with your build process
// package.json
{
"scripts": {
"prebuild": "node spaceblock-build.js --apiKey $API_KEY --input src/index.html --output dist/index.html",
"build": "your-build-command"
}
}How it works:
Use build-time inlining + set up webhooks to trigger rebuilds when content changes. Perfect for high-traffic sites.
Setup: Same as Level 2, but configure your CI/CD (Vercel, Netlify, GitHub Actions) to rebuild when content changes. Content stays fresh + loads instantly!
| Approach | First Visit | Return Visit | Best For |
|---|---|---|---|
| Runtime Caching | 450ms | 155ms ā | Development, Testing |
| Build-Time Inline | 155ms ā ā ā | 155ms ā ā ā | Production Sites |
| Hybrid | 155ms ā ā ā | 155ms ā ā ā | High-Traffic Sites |
Clear Cache:
SpaceBlock.clearCache();
// or
localStorage.removeItem('spaceblock_cache_YOUR_PROJECT_ID');Test Inline Content:
// In browser console
const inline = document.getElementById('spaceblock-data');
console.log(inline ? 'ā
Inline data found!' : 'ā No inline data');SpaceBlock includes a full-featured blog system. Your clients can create, edit, and publish blog posts from the dashboard. You can fetch these posts via the public API.
GET /api/public/blog?apiKey=YOUR_API_KEY
// Optional query parameters:
// ?page=1&limit=10 - Pagination
// ?tag=news - Filter by tagResponse:
{
"posts": [
{
"id": "abc123",
"title": "My First Post",
"slug": "my-first-post",
"excerpt": "A short summary...",
"featuredImage": "https://...",
"author": "John Doe",
"tags": ["news", "updates"],
"publishedAt": "2025-01-15T10:00:00Z",
"createdAt": "2025-01-14T08:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 25,
"totalPages": 3
}
}GET /api/public/blog/my-first-post?apiKey=YOUR_API_KEYResponse (Block-Based Content):
{
"post": {
"id": "abc123",
"title": "My First Post",
"slug": "my-first-post",
"excerpt": "A short summary...",
"featuredImage": "https://...",
"author": "John Doe",
"tags": ["news", "updates"],
"content": [
{ "id": "b1", "type": "text", "content": "Intro paragraph..." },
{ "id": "b2", "type": "heading", "content": "Section Title", "level": 2 },
{ "id": "b3", "type": "image", "content": "https://...", "size": "large" },
{ "id": "b4", "type": "quote", "content": "A highlighted quote" },
{ "id": "b5", "type": "subblog", "content": "Related", "articles": [...] }
],
"publishedAt": "2025-01-15T10:00:00Z"
}
}text
Plain paragraph text
heading
Section heading with level: 2 (H2) or 3 (H3)
image
Image with size: full (100%), large (75%), medium (50%), small (25%)
quote
Highlighted quote or callout
subblog NEW
Carousel of related articles with articles array
Each article: id, title, excerpt, image, link, variant (default/minimal/card/overlay/featured)
// pages/blog/[slug].tsx - Render block-based content
const sizeWidths = { full: '100%', large: '75%', medium: '50%', small: '25%' };
function renderBlock(block) {
switch (block.type) {
case 'text': return <p key={block.id}>{block.content}</p>;
case 'heading':
return block.level === 3
? <h3 key={block.id}>{block.content}</h3>
: <h2 key={block.id}>{block.content}</h2>;
case 'image':
return <img key={block.id} src={block.content}
style={{ width: sizeWidths[block.size || 'full'] }} />;
case 'quote':
return <blockquote key={block.id}>{block.content}</blockquote>;
case 'subblog':
return (
<div key={block.id} className="carousel">
{block.content && <h3>{block.content}</h3>}
<div className="carousel-items">
{block.articles?.map(a => (
<div key={a.id} className={`card card--${a.variant}`}>
{a.image && <img src={a.image} alt={a.title} />}
<h4>{a.title}</h4>
<p>{a.excerpt}</p>
{a.link && <a href={a.link}>Read more</a>}
</div>
))}
</div>
</div>
);
default: return null;
}
}
export default function BlogPostPage({ params }) {
const [post, setPost] = useState(null);
useEffect(() => {
fetch(`/api/public/blog/${params.slug}?apiKey=YOUR_API_KEY`)
.then(res => res.json())
.then(data => setPost(data.post));
}, [params.slug]);
if (!post) return <div>Loading...</div>;
return (
<article>
<h1>{post.title}</h1>
{post.featuredImage && <img src={post.featuredImage} />}
<div className="content">
{post.content.map(block => renderBlock(block))}
</div>
</article>
);
}Pass your API key in one of two ways:
?apiKey=YOUR_KEYx-api-key: YOUR_KEYCollections let you manage repeatable content like team members, products, portfolio items, or any structured data. Create collections in your dashboard, add items with images and detailed content, then manage everything from a clean interface.
Photos, names, roles, detailed bios
Images, names, descriptions, features
Screenshots, project names, details
Avatars, names, companies, quotes
Fetch your collection data programmatically using the public API. Perfect for rendering dynamic content like team members, products, or portfolio items on your website.
GET /api/public/project/{projectId}/collections/{collectionName}?apiKey=YOUR_API_KEY
// collectionName can be the slug or name (case-insensitive)Response:
{
"collection": {
"id": "coll123",
"name": "Team Members",
"slug": "team-members",
"description": "Our amazing team",
"schema": [...]
},
"items": [
{
"id": "item1",
"title": "Sarah Chen",
"slug": "sarah-chen",
"image": "https://...",
"description": "CEO & Founder",
"tags": ["leadership", "founder"],
"data": { "contentBlocks": [...] },
"order": 0,
"updatedAt": "2025-01-15T10:00:00Z"
}
]
}GET /api/public/project/{projectId}/collections/{collectionName}/{itemSlug}?apiKey=YOUR_API_KEYResponse (with Content Blocks):
{
"id": "item1",
"title": "Sarah Chen",
"slug": "sarah-chen",
"image": "https://...",
"description": "CEO & Founder",
"tags": ["leadership", "founder"],
"contentBlocks": [
{ "id": "b1", "type": "text", "content": "Sarah founded the company in 2020..." },
{ "id": "b2", "type": "heading", "content": "Background", "level": 2 },
{ "id": "b3", "type": "text", "content": "With over 15 years of experience..." },
{ "id": "b4", "type": "image", "url": "https://...", "alt": "Team photo", "size": "large" },
{ "id": "b5", "type": "quote", "content": "Innovation starts with people", "author": "Sarah Chen" },
{ "id": "b6", "type": "divider" }
],
"data": { ... },
"order": 0,
"updatedAt": "2025-01-15T10:00:00Z",
"collection": {
"id": "coll123",
"name": "Team Members",
"slug": "team-members",
"description": "Our amazing team"
}
}text
Plain paragraph text. Properties: content
heading
Section heading with level: 2 (H2) or 3 (H3)
image
Image with url, alt, and size: full, large, medium, small
quote
Highlighted quote with optional author attribution
divider
Horizontal separator line
Collection items support tags for categorization and filtering. Tags are returned as an array of strings.
// Filter items by tag in your frontend
const items = data.items;
const leadershipItems = items.filter(item =>
item.tags?.includes("leadership")
);
// Render tags as badges
{item.tags?.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}Tags can be added/edited in the CMS collection item editor.
// components/CollectionItemDetail.tsx
const sizeWidths = { full: '100%', large: '75%', medium: '50%', small: '25%' };
function renderContentBlock(block) {
switch (block.type) {
case 'text':
return <p key={block.id}>{block.content}</p>;
case 'heading':
return block.level === 3
? <h3 key={block.id}>{block.content}</h3>
: <h2 key={block.id}>{block.content}</h2>;
case 'image':
return (
<img
key={block.id}
src={block.url}
alt={block.alt || ''}
style={{ width: sizeWidths[block.size || 'full'] }}
/>
);
case 'quote':
return (
<blockquote key={block.id}>
<p>{block.content}</p>
{block.author && <cite>ā {block.author}</cite>}
</blockquote>
);
case 'divider':
return <hr key={block.id} />;
default:
return null;
}
}
export default function CollectionItemPage({ projectId, collectionName, itemSlug }) {
const [item, setItem] = useState(null);
useEffect(() => {
fetch(`/api/public/project/${projectId}/collections/${collectionName}/${itemSlug}?apiKey=YOUR_API_KEY`)
.then(res => res.json())
.then(data => setItem(data));
}, [projectId, collectionName, itemSlug]);
if (!item) return <div>Loading...</div>;
return (
<article>
<h1>{item.title}</h1>
{item.image && <img src={item.image} alt={item.title} />}
<p className="subtitle">{item.description}</p>
<div className="content">
{item.contentBlocks?.map(block => renderContentBlock(block))}
</div>
</article>
);
}Pass your API key in one of two ways:
?apiKey=YOUR_KEYx-api-key: YOUR_KEYFind your project ID and API key in your project dashboard settings.
Fetch pages created via the Page Builder. Use this API to build dynamic navigation, render custom pages, and manage page content.
GET /api/public/pages/{projectId}?apiKey=YOUR_API_KEYResponse:
{
"pages": [
{
"id": "page-123",
"name": "About Us",
"slug": "about-us",
"path": "/about-us",
"published": true,
"createdAt": "2026-01-15T10:00:00Z"
},
{
"id": "page-456",
"name": "Contact",
"slug": "contact",
"path": "/contact",
"published": true,
"createdAt": "2026-01-16T14:30:00Z"
}
]
}Returns all pages (published and unpublished). Requires API key validation.
GET /api/public/pages/{projectId}/listResponse:
{
"pages": [
{
"id": "page-123",
"name": "About Us",
"slug": "about-us",
"path": "/about-us",
"title": "About Our Company",
"description": "Learn more about us",
"createdAt": "2026-01-15T10:00:00Z"
}
]
}Returns only published pages. No API key required.
GET /api/public/pages/{projectId}/{pageSlug}Response:
{
"page": {
"id": "page-123",
"name": "About Us",
"slug": "about-us",
"path": "/about-us",
"title": "About Our Company",
"published": true
},
"elements": [
{
"id": "el-1",
"elementId": "hero-title",
"type": "heading",
"content": { "text": "Welcome" },
"zone": "main",
"order": 0
}
]
}// Build dynamic navigation from CMS pages
function Navigation() {
const [pages, setPages] = useState([])
useEffect(() => {
fetch(`/api/public/pages/${projectId}?apiKey=YOUR_API_KEY`)
.then(res => res.json())
.then(data => setPages(data.pages.filter(p => p.published)))
}, [])
return (
<nav>
<Link href="/">Home</Link>
{pages.map(page => (
<Link key={page.id} href={page.path}>{page.name}</Link>
))}
</nav>
)
}Pass your API key in one of two ways:
?apiKey=YOUR_KEYx-api-key: YOUR_KEYAdd data-cms-id to <img> tags:
<img
data-cms-id="hero-image"
src="hero.jpg"
alt="Hero"
style="object-fit: cover;"
/>Dashboard features: Edit URL, Alt text, and Fit mode (cover, contain, fill, none, scale-down)
Add data-cms-id to <a> tags:
<a data-cms-id="cta-button" href="/signup">
Get Started
</a>
<nav>
<a data-cms-id="nav-home" href="/">Home</a>
<a data-cms-id="nav-about" href="/about">About</a>
</nav>Dashboard features: Edit URL (href attribute)