Skip to content

Content

Blocks

Render a JSON tree of components, as a visual page builder like Builder.io does, while shipping only the block types a page names.

On this page

Some pages are not written by developers at all. A marketing team assembles a landing page in a visual editor, or a CMS decides which components appear on a page and in what order, and your app only has to render what it is given. When a developer writes the layout in a .svelte file you don’t need any of this, because the components alone are enough.

For the cases where content decides the layout, a block page is data: a tree of nodes in which each node names a component type and its props. blocks() renders that tree the same way markdown() renders .svx, as an entry body that you place with <Region of={entry.body} />. It is the rendering half of a visual page builder: your CMS (or a JSON file) owns the layout, and ogygia owns the rendering.

The panel below is a block tree (a Hero, a Grid of two Features, and one interactive CounterBlock) rendered live from the data shown further down this page.

This panel is a block tree

Rendered from JSON through the registry.

Static blocks

Hero, Grid and Feature are plain imports — zero JS.

Only what is named

A registry of thousands; this page names four.

Interactive block (wake: load)
3

The only block on this page that ships JS.

Register your blocks

The registry maps a block type name to a component. A static block is a with { region: 'raw' } import, which renders inline and ships no JS but is still code-split, so a page loads the CSS (and, for interactive blocks, the JS) of only the block types it names. An interactive block uses with { wake: … } instead, which bakes in its schedule (load, idle, visible or a media query decides when it hydrates) and makes its JS load only when a page names it.

// 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 region, code-split and resolved to a region by the registry; region: 'raw' bakes in no schedule (static, HTML only) and wake: bakes one in (interactive). A plain import would also work, but its CSS would be folded into the page stylesheet, so a large registry would cost every page the styles of every block. The marks are what keep a page’s cost limited to the blocks it renders.

Writing one with { region: 'raw' } line per block means N imports that quietly drift out of sync with the folder. The regions() macro collapses them into one line, import.meta.og.regions('./blocks/*.svelte'), which globs the folder and emits a raw-region import for each match, keyed by basename. Blocks that need a wake: schedule stay as manual imports spread on top.

You decide which component each type means; there is no guessing and no auto-discovery. An unregistered type is skipped with a dev warning and is 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, so a Grid can hold Features and a section can hold a grid. That nesting is what makes the data a page tree rather than a flat stack.

Render it

blocks() is a content source. Point a collection at your page JSON, and each 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)
});

You then render it exactly like a markdown body, with the same get() and the same <Region>. Call get() in a universal +page.ts load, or directly in the page component with await, so that its data reaches the render by reference within 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 that says so. On a csr = false page a universal load never runs on the client, so it is server code without that boundary.

The CSS of each block travels with its render. The page never imports Hero or Feature, so their styles are not in any page stylesheet. Instead, during the server pass the region links each rendered block’s hashed stylesheet into <head>, deduplicated and only for the block types the page uses, so a registry of 100 blocks costs a page exactly the stylesheets of the blocks it renders.

No collection? A ten-line recipe

The content source is the recommended path, but when you already have a tree in hand (a prop, a literal, a one-off CMS call), you don’t need a collection. blocks.resolve(tree, registry) turns the tree into region nodes. It must run on the server, where the signing key lives, which means a +page.server.ts load, a remote, or the SSR pass of a csr = false page. You render the 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 all there is to it: blocks.resolve walks the tree from type to region and skips unknown types, and your component does the rendering. It is exactly what the blocks() source runs internally. ogygia ships no component for this because ten lines that you own are easier to adapt than a component you can’t see into.

Data from a CMS (Builder.io)

Builder is headless, meaning it serves your pages as JSON over an HTTP API. Write a loader that fetches them and render through blocks(). With your public key filled in, the following is a working 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 navigation or prerendering, and nothing else in your app changes, because it is a content collection like any other.

The live demo on this site uses local JSON in exactly Builder’s shape: /demo/builder/home and /demo/builder/pricing are full pages assembled as data and rendered as islands, and only the interactive block ships JS.

Thousands of blocks, only a few shipped

The registry can hold a thousand blocks while a page that names three loads three chunks, and the other 997 never ship. This is not a special case but a consequence of the model: the registry (the vocabulary) is fixed at build time, the page JSON picks the arrangement at runtime, and each block is a held region whose client chunk loads only when it is on the page. The bundle therefore stays small however large the registry grows; Held regions covers the mechanism.