Content
Blocks
Render a JSON tree of components. A Builder.io-style page builder, where only the block types that a page names ever ship.
On this page
A block page is data: a tree of nodes. Each node names a component type plus its props. blocks() renders that tree the same way that markdown() renders .svx: as an entry body that 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
Use blocks when content decides layout: marketing pages assembled in a visual editor, CMS-driven landing pages, each case where a non-developer arranges components. When a developer writes the layout in a .svelte file, you do not need this. Only 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 lower on 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. Thus a page loads only the CSS (and, for interactive blocks, the JS) of the block types that it names. An interactive block uses with { wake: … } in its place. That 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 operate too. But its CSS would fold into the page stylesheet. Then a large registry pays for each block on each page. The marks are what keep the cost of a page to the blocks that 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'). It 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. There is no guessing and no auto-discovery. An unregistered type is skipped, with a dev warning. It is never rendered as something that 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 the body of each entry 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: the same get(), the same <Region>. Call get() in a universal +page.ts load, or directly 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`
The data of a server load crosses a serialization boundary. A body is a same-pass SSR value: a live render, not wire data. Thus it cannot cross. ogygia stops you, with an error that says exactly this. On a csr = false page, a universal load never runs client-side. Thus it is server code, with none of the boundary.
The CSS of each block travels with the render. The page never imports Hero or Feature. Thus their styles are on no page stylesheet. During the server pass, the region links the hashed stylesheet of each rendered block into <head>, deduped, and only for the block types that the page uses. A registry of 100 blocks costs a page exactly the sheets for the blocks that it renders.
No collection? A ten-line recipe
The content source is the recommended path. But when you already hold a tree (a prop, a literal, a one-off CMS call), you do not 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 that 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 is the full thing. blocks.resolve does the type → region walk, and skips unknown types. Your component does the rendering. It is exactly what the blocks() source runs internally. There is no shipped component to import, because ten lines that 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 render 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 full 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, the same as all others.
The live demo on this site uses local JSON in the exact shape of Builder: /demo/builder/home and /demo/builder/pricing. Full 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 a special case. 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. The client chunk of a held region loads only when it is on the page. Thus the bundle stays small, however large the registry grows.