Content
Blocks
Render a JSON tree of components. A Builder.io-style page builder where only the block types a page names ever ship.
On this page
A block page is data: a tree of nodes, each naming a component type plus its props. blocks() renders that tree the same way markdown() renders .svx, as an entry body you drop in with <Region of={entry.body} />. It is the render half of a visual page builder: your CMS (or a JSON file) owns the layout; ogygia owns the rendering.
When to use
Reach for blocks when content decides layout: marketing pages assembled in a visual editor, CMS-driven landing pages, anything where a non-developer arranges components. If a developer writes the layout in a .svelte file, you do not need this; just write the components.
The panel below is a block tree (a Hero, a Grid of two Features, and one interactive CounterBlock) rendered live from the data further down this page.
This panel is a block tree
Rendered from JSON through the registry.
Hero, Grid and Feature are plain imports — zero JS.
A registry of thousands; this page names four.
Register your blocks
The registry maps a block type name to a component. A static block is a with { region: 'raw' } import: it renders inline and ships zero JS, but it is code-split, so a page loads only the CSS (and, for interactive blocks, the JS) of the block types it actually names. An interactive block uses with { wake: … } instead, which bakes its schedule and makes its JS load only when a page names it (load · idle · visible · a media query decides when it hydrates).
// a .ts module: a `region`/`wake` mark here makes a held block
import Hero from './blocks/Hero.svelte' with { region: 'raw' };
import Grid from './blocks/Grid.svelte' with { region: 'raw' };
import Feature from './blocks/Feature.svelte' with { region: 'raw' };
import Counter from './blocks/Counter.svelte' with { wake: 'load' };
export const registry = { Hero, Grid, Feature, Counter };Both marks make the component a held block: code-split, resolved to a region by the registry. region: 'raw' bakes no schedule (static, HTML only); wake: bakes one (interactive). A plain import would work too, but its CSS would fold into the page stylesheet, so a large registry pays for every block on every page. The marks are what keep a page’s cost to the blocks it renders.
The
with { region: 'raw' }lines are N imports that quietly fall out of sync with the folder. Theregions()macro collapses them into one line,import.meta.og.regions('./blocks/*.svelte'), that globs the folder and emits a raw-region import per match, keyed by basename. Blocks that need awake:schedule stay a manual import, spread on top.
You decide which component a type means, with no guessing and no auto-discovery. An unregistered type is skipped with a dev warning, never rendered as something it is not.
A page is JSON
[
{ "type": "Hero", "props": { "title": "Welcome" } },
{ "type": "Grid", "children": [
{ "type": "Feature", "props": { "title": "Fast" } },
{ "type": "Feature", "props": { "title": "Typed" } }
] },
{ "type": "Counter", "props": { "start": 3 } }
]Nodes nest through children: a Grid holds Features, a section holds a grid. That is what makes it a page tree and not a flat stack.
Render it
blocks() is a content source. Point a collection at your page JSON and every entry’s body is the rendered tree:
import { content, blocks } from 'ogygia/content';
import { registry } from '$lib/blocks';
export const pages = content({
loader: blocks(import.meta.glob('./pages/*.json'), registry)
});Then render it exactly like a markdown body: same get(), same <Region>. Call get() in a universal +page.ts load (or straight in the page component with await): its data reaches the render by reference, in the same server pass.
// universal, NOT +page.server.ts
import { pages } from '$lib/blocks/pages';
export const load = async () => ({ page: await pages.get('landing') });<script>
import { Region } from 'ogygia';
let { data } = $props();
</script>
<Region of={data.page.body} />Not `+page.server.ts`
A server load’s data crosses a serialization boundary, and a body is a same-pass SSR value (a live render, not wire data), so it cannot cross (ogygia stops you with an error saying exactly this). On a csr = false page a universal load never runs client-side, so it is server code with none of the boundary.
Every block’s CSS travels with the render: the page never imports Hero or Feature, so their styles are on no page stylesheet. The region links each rendered block’s own hashed stylesheet into <head> during the server pass, deduped, only for the block types the page actually uses. A registry of 100 blocks costs a page exactly the sheets for the blocks it renders.
No collection? A ten-line recipe
The content source is the recommended path. But if you already hold a tree (a prop, a literal, a one-off CMS call), you don’t need a collection. blocks.resolve(tree, registry) turns it into region nodes (it must run server-side, where the signing key lives: a +page.server.ts load, a remote, or the SSR pass of a csr = false page). You render those nodes with a small recomposer you own:
<!-- yours to keep and tweak -->
<script>
import { Region } from 'ogygia';
import { blocks } from 'ogygia/content';
import Self from './BlockTree.svelte';
let { tree, registry, nodes } = $props();
const resolved = $derived(nodes ?? blocks.resolve(tree, registry));
</script>
{#each resolved as node, i (i)}
<Region of={node.of}>{#if node.children?.length}<Self nodes={node.children} />{/if}</Region>
{/each}<!-- use it -->
<BlockTree tree={page} {registry} />That’s the whole thing: blocks.resolve does the type → region walk (skipping unknown types), and your component does the rendering. It’s exactly what the blocks() source runs internally; there’s no shipped component to import because ten lines you own beat a black box.
Data from a CMS (Builder.io)
Builder is headless: it serves your pages as JSON over an HTTP API. Write a loader that fetches it and renders through blocks(). Copy this, drop in your public key, and you have a Builder-driven site:
import { content, blocks, mapRaw } from 'ogygia/content';
import type { RawSource } from 'ogygia/content';
import { registry } from './blocks';
const KEY = 'YOUR_BUILDER_PUBLIC_KEY';
const API = `https://cdn.builder.io/api/v3/content/page?apiKey=${KEY}`;
// Builder element → block node.
const toTree = (els = []) =>
els.filter((e) => e.component?.name).map((e) => ({
type: e.component.name,
props: e.component.options ?? {},
children: e.children ? toTree(e.children) : undefined
}));
// A raw source over Builder's Content API: refs() lists every page, get() fetches ONE.
const builderApi: RawSource<any> = {
async refs() {
const { results } = await fetch(`${API}&limit=100`).then((r) => r.json());
return results.map((r) => ({ id: (r.data?.url ?? r.id).replace(/^\//, ''), value: r }));
},
async get(id) {
const { results } = await fetch(`${API}&limit=1&url=/${id}`).then((r) => r.json());
return results[0] ? { id, value: results[0] } : null;
}
};
// Convert each result to a block tree, then render through blocks().
export const pages = content({
loader: blocks(mapRaw(builderApi, (r) => ({ blocks: toTree(r.data?.blocks) })), registry)
});That is the whole integration. get(slug) fetches one page on demand; list() / ids() drive a nav or prerender. Nothing else in your app changes; it is a content collection like any other.
The live demo on this site uses local JSON in Builder’s exact shape: /demo/builder/home and /demo/builder/pricing. Whole pages, assembled as data, rendered as islands; only the interactive block ships JS.
Thousands of blocks, only a few shipped
The registry can hold a thousand blocks. A page that names three loads three chunks; the other 997 never ship. That is not special-cased; it falls out of the model. The registry is fixed at build (the vocabulary); the page JSON picks the arrangement at runtime. Each block is a held region, and a held region’s client chunk loads only when it is actually on the page. So the bundle stays tiny no matter how big the registry grows.