Skip to Content
GuidesBring your own components

Bring your own components

Every code block on this page is a real file in examples/host-blocks, compiled and tested on every CI run — a sync test fails if this page drifts from the code.

A generated workspace should look like your product, not like ours. So the renderer never insists on our widgets: you map each block type to a component from your own design system, and we supply the spec, the validation, and the data binding.

The split is the whole idea:

We ownYou own
What a workspace is (the spec)What it looks like (the components)
Whether a spec is allowed (the gate)Your design system, a11y, interactions
Fetching + shaping each block’s dataYour API and authorization

There are three levels. Most teams stop at level 2.

Level 1 — theme tokens (minutes)

Keep our default blocks, restyle them with the --we-* theme tokens. Good for a first demo; you’ll outgrow it the moment your design system has opinions.

:root { --we-accent: #5b5bd6; --we-radius: 10px; }

Level 2 — swap in your components (the main path)

This is what a real integration looks like. Start from your contract — it is unchanged by any of this:

src/contract.ts
// The vendor's data contract — unchanged by which components render it. // The contract is what the gate enforces; components are a separate concern. import { defineEntity } from "@ticora/core"; import { z } from "zod"; export interface Issue { id: string; title: string; state: "backlog" | "started" | "done"; assignee: string; points: number; } const ISSUES: Issue[] = [ { id: "ENG-101", title: "Flaky checkout test", state: "started", assignee: "ada", points: 3 }, { id: "ENG-102", title: "Rate-limit the webhook", state: "backlog", assignee: "grace", points: 5 }, { id: "ENG-103", title: "Ship dark mode", state: "done", assignee: "ada", points: 8 }, { id: "ENG-104", title: "Upgrade the query planner", state: "backlog", assignee: "linus", points: 13 }, ]; export const issueContract = defineEntity({ name: "issue", schema: z.object({ id: z.string(), title: z.string(), state: z.enum(["backlog", "started", "done"]), assignee: z.string(), points: z.number(), }), capabilities: { filterable: ["state", "assignee"], sortable: ["points"], groupable: ["state", "assignee"], aggregations: { points: ["sum", "avg"] }, defaultLimit: 50, maxLimit: 100, }, // Your real fetch hits your API. The end-user's auth is passed through // unchanged, so your own authorization still decides what they can see. fetch: async () => ISSUES, });

Now write components. A block component receives its block from the spec plus the resolved data state for its binding — nothing else:

src/blocks.tsx
// YOUR components. This is the whole integration surface: a component per // block type, receiving the block from the spec plus its resolved data state. // // Nothing here imports our UI package — these are your design system's // components, so a generated workspace looks like the rest of your product. import type { BlockComponentProps } from "@ticora/react"; import type { Issue } from "./contract"; /** Your table. `data` is the rows the block's query returned. */ export function IssueTable({ block, data, status }: BlockComponentProps) { const rows = (data as Issue[] | undefined) ?? []; const title = (block.config as { title?: string }).title ?? "Issues"; // The renderer shows a skeleton while loading and a broken-block on error, // so by here you can assume real data — but the state is yours if you want it. if (status === "loading") return <YourSkeleton label={title} />; return ( <section className="your-card"> <h3 className="your-card__title">{title}</h3> <table className="your-table"> <thead> <tr> <th>Issue</th> <th>Assignee</th> <th>Points</th> </tr> </thead> <tbody> {rows.map((issue) => ( <tr key={issue.id}> <td> <YourBadge state={issue.state} /> {issue.title} </td> <td>{issue.assignee}</td> <td>{issue.points}</td> </tr> ))} </tbody> </table> </section> ); } /** Your board. A "groups" binding gives you `{ key, rows }` buckets. */ export function IssueBoard({ block, data }: BlockComponentProps) { const groups = (data as { group: string; rows: Issue[] }[] | undefined) ?? []; const title = (block.config as { title?: string }).title ?? "Board"; return ( <section className="your-card"> <h3 className="your-card__title">{title}</h3> <div className="your-board"> {groups.map((group) => ( <div className="your-board__column" key={group.group}> <h4> {group.group} <span className="your-count">{group.rows.length}</span> </h4> {group.rows.map((issue) => ( <article className="your-board__card" key={issue.id}> {issue.title} </article> ))} </div> ))} </div> </section> ); } // Your own primitives — the point is that these are already in your app. function YourBadge({ state }: { state: Issue["state"] }) { return <span className={`your-badge your-badge--${state}`}>{state}</span>; } function YourSkeleton({ label }: { label: string }) { return <div className="your-skeleton" aria-label={`Loading ${label}`} />; }

Register them, and you’re done:

src/App.tsx
// Registration: map each block type to YOUR component. That's the swap. // // `defineBlock` declares what the component can render (`accepts.shape`), which // is checked against the block registry — so a component wired to the wrong // data shape fails at registration, not in front of a user. import { WorkspaceProvider, WorkspaceRenderer, defineBlock } from "@ticora/react"; import type { WorkspaceSpec } from "@ticora/core"; import { issueContract } from "./contract"; import { IssueBoard, IssueTable } from "./blocks"; export const hostBlocks = [ defineBlock({ type: "CasesTable", accepts: { shape: "rows", entities: ["issue"] }, component: IssueTable, }), defineBlock({ type: "GroupedBoard", accepts: { shape: "groups", entities: ["issue"] }, component: IssueBoard, }), ]; export function Workspace({ spec }: { spec: WorkspaceSpec }) { return ( <WorkspaceProvider contracts={[issueContract]} blocks={hostBlocks}> <WorkspaceRenderer spec={spec} /> </WorkspaceProvider> ); }

accepts.shape is checked against the block registry at registration, so a component wired to the wrong data shape fails immediately — not in front of a user. The shapes are rows, groups, aggregates, and none (static).

Using the components you already have

You register one component per block type — and inside it you compose the components your app already ships. You don’t register your <Table> or <Button> primitives directly.

That’s because the block contract hands you { block, data, status, … } — a data-shape API — while your design system component has its own props (columns, rows, onSort). Something has to map between the two. Doing it in a thin adapter keeps the coupling explicit and one-directional: we never import your components, and you never fork ours.

In practice the adapter is a few lines:

import { YourDataTable } from "@your-org/design-system"; defineBlock({ type: "CasesTable", accepts: { shape: "rows" }, component: ({ block, data }) => ( <YourDataTable columns={ISSUE_COLUMNS} rows={data as Issue[]} title={(block.config as { title?: string }).title} /> ), });

YourDataTable is untouched — its sorting, pagination, theming, and accessibility stay yours. Six block types means roughly six small adapters, and the next section shows you don’t have to write them all at once.

Swap one block at a time

You don’t have to do all six at once. Register your own where you have them and let the default blocks cover the rest — the registry is a plain map, so a partial swap is a normal state, not a migration:

blocks={[...defaultBlocks, ...hostBlocks]} // yours win on conflict

Your components can’t widen the gate

Worth being explicit, because it’s the thing partners ask about: swapping components changes the pixels, never the policy. A spec asking for data your contract doesn’t expose is refused the same way it always was.

src/host-blocks.test.ts
// The guarantee, as a test: the gate is unchanged by whose components render. // The same spec that BUILDs here would BUILD against the default blocks — and // a spec asking for data the contract doesn't expose is REJECTed either way. import { validateSpec } from "@ticora/core"; import { expect, test } from "vitest"; import { issueContract } from "./contract"; const ctx = { contracts: { issue: issueContract } }; const boardSpec = { specVersion: 1, title: "Sprint board", timezone: "UTC", layout: { columns: 12 }, refresh: { mode: "manual" }, blocks: [ { id: "blk_board", type: "GroupedBoard", frame: { x: 0, y: 0, w: 12, h: 6 }, config: { title: "By state" }, binding: { entity: "issue", query: { groupBy: "state" } }, }, ], }; test("a spec over the contract BUILDs — your components render it", () => { expect(validateSpec(boardSpec, ctx).verdict).toBe("BUILD"); }); test("your components do NOT widen what the gate allows", () => { // "salary" is not on the issue contract. Swapping in your own components // changes the pixels, never the policy — this is still refused. const exfil = { ...boardSpec, blocks: [ { ...boardSpec.blocks[0], binding: { entity: "issue", query: { sort: [{ field: "salary", dir: "desc" }] } }, }, ], }; const verdict = validateSpec(exfil, ctx); expect(verdict.verdict).toBe("REJECT"); });

Level 3 — your own block types

Levels 1–2 reuse the six built-in types. If you need a block we don’t ship — a burndown, a heatmap — the validator has to learn it too, because it gates every block’s config, binding shape, and size. So a custom type is two registrations: a registry entry for the gate, and a component for the renderer.

import { defineBlockType, extendRegistry, DEFAULT_REGISTRY } from "@ticora/core"; import { z } from "zod"; export const burndown = defineBlockType({ type: "Burndown", bindingShape: "aggregate", // MUST be .strict() — a loose schema would let anything into config and the // gate would never refuse it. defineBlockType throws if you forget. config: z.object({ title: z.string().optional() }).strict(), minSize: { w: 4, h: 3 }, maxSize: { w: 12, h: 8 }, }); export const registry = extendRegistry(DEFAULT_REGISTRY, [burndown]);

Then use that registry on both sides — the gate and the renderer:

// where you validate (generation / save): validateSpec(candidate, { contracts, registry }); // where you render: <WorkspaceProvider registry={registry} contracts={[issueContract]} blocks={[defineBlock({ type: "Burndown", accepts: { shape: "aggregate" }, component: Burndown })]} >

Miss the registry on the provider and registration throws immediately — the common mistake is extending it for the gate and forgetting the renderer, so it fails loudly rather than rendering a broken block.

Your custom type is then held to exactly the same standard as a built-in: config outside its schema, a binding whose shape doesn’t match the declaration, or a frame outside its size bounds all come back as typed REJECTs.

Until you need one, prefer an existing type: CasesTable (rows), GroupedBoard (groups), KpiCards and Graph (aggregate), CaseQueue (rows), FilterBar (static).

Prove your components in CI

Swapping components means an SDK upgrade could change what a block receives. Assert the contract in your CI so that surfaces as a failing test, not a support ticket:

import { assertBlockContract } from "@ticora/react/testing"; import { render } from "@testing-library/react"; import { IssueTable, IssueBoard } from "./blocks"; it.each([ ["CasesTable", IssueTable], ["GroupedBoard", IssueBoard], ])("%s satisfies the block contract", (type, Component) => { assertBlockContract(Component, { type, render }); });

It renders your component through every state the renderer can hand it — loading, success, empty, error, and stale-while-refetching (the one most components forget) — and fails naming the states that broke.

Pass the registry type, not a hand-written shape: the kit derives the shape the renderer actually produces, so you can’t get a passing assertion against data your block never receives. (Graph, for instance, is an aggregate block, not rows.)

You supply render, so the kit stays free of react-dom and any testing library — it works with whatever you already use.

What to check before you ship

  • Handle status === "loading" and "error" if you want your own treatments; the renderer supplies a skeleton and a broken-block state otherwise.
  • Blocks render inside your shell — see the embedding notes for the container contract and the isolation guarantees.
  • Run your components against the gate in CI, exactly like the test above.
Last updated on