Building Correctly — Your App IS the On-Chain Component
The single most common mistake when building on ChainRender is treating it as a library a React app calls to produce a hash, instead of the framework the app is built in and rendered from. This page draws that line sharply, because getting it wrong produces an app that looks like ChainRender but isn't on-chain at all.
Building with an AI agent? Point it at the ChainRender MCP server & app-builder skill (
@chainrender/mcp+ thechainrender-app-builderSKILL.md). The skill teaches the correct compose → grade → compile → validate → render workflow and the anti-patterns below, so the agent builds a real on-chain app instead of a conventional dashboard. This is the recommended path for AI-assisted builds.
A complete, copyable example
This is the whole correct loop in one place. The balance is read and rendered on-chain by the Slot — you never
fetch it yourself. Clone this shape (a full runnable version ships in the app-builder skill as
examples/correct-react-app.tsx):
import { compileJsx } from "@chainrender/client";
import { renderComponent } from "@chainrender/client/runtime";
const DEPLOYMENT = { rpc: "<your-rpc>", router: "<RenderRouter>", registry: "<ComponentRegistry>" };
// 1. COMPILE — the Slot declares an on-chain read; the interpreter resolves it at render time.
const { hex, hash } = compileJsx(`
<Box direction="column" variant="surface">
<Text role="heading">Account Balance</Text>
<Slot bind="balance" arg="0xd8dA...6045" />
</Box>`);
// 2. REGISTER — write the bytes on-chain once (wallet tx to the ComponentRegistry with `hex`).
// After this, the component lives on-chain forever, addressed by `hash`.
// 3. RENDER — one eth_call returns the fully on-chain-rendered HTML, balance included. Display THAT.
const html = await renderComponent(DEPLOYMENT, hash);
document.getElementById("app")!.innerHTML = html; // ← the on-chain UI. Not hand-built React.
The balance in that output was read and rendered on-chain during step 3 — there is no getBalance call in your
code. That is the difference between a ChainRender app and a viem dashboard.
Nuance: three things the ethos does NOT mean (avoid over-compliance)
Understanding "the component is the app" is the first step. Over-applying it produces an app that is rigid and barely functional. Three clarifications:
A client-side resolver is NOT Grade A — an on-chain resolver contract is
Routing data through a resolver does not make it on-chain. There are two kinds of resolver, and they grade differently:
- Client-side resolver (a JS function you write that calls
publicClient.getBlock()/getBalance(), e.g. themakeProviderResolvershelpers) — this is the CLIENT_FETCH path. The data is fetched off-chain in the browser and dropped into the slot. It is convenient and legitimate, but it grades C (Live), not A. WritingblockHeight: async () => publicClient.getBlock()is still an off-chain fetch — moving it into a resolver function does not make it trustless. - On-chain resolver contract (a resolver address in the Slot, e.g.
<Slot resolver="0xEthBalanceResolver…" arg="0x…" />) — the value is read and rendered on-chain by that contract during theeth_call. This is the Grade A path.
So: for real trustlessness, bind a Slot to an on-chain resolver contract address, not a client-side JS function. Client-side resolvers are a pragmatic fallback (some data, like an arbitrary wallet's ENS name, is inherently client-side) — use them knowingly, and let the grade tell the truth (they land at C).
Ordinary client-side UI is fine — misrepresenting off-chain data as on-chain is the violation
The ethos does not forbid all React state. A unit converter that does arithmetic in the browser, form input state, tab switching, a wallet-connect button — these are ordinary client concerns and are perfectly fine as normal React/HTML. You do not need to turn a client-side calculator into an on-chain transaction to be "compliant." The actual violation is narrower: fetching on-chain data with viem and hand-rendering it as if it were rendered on-chain, while claiming the app is fully on-chain. Pure client-side computation isn't on-chain data and never claimed to be — so it's not a violation. Don't contort ordinary UI into on-chain submissions; just don't dress up off-chain data as on-chain.
Rendering requires a registered component — that's by design, not a bug
renderComponent / <ChainRenderView> render by hash via an eth_call, so the component must be registered
on-chain first. There is no "render from raw source" — that's the whole point (the chain is the renderer). During
authoring you preview with grade() / compileComponent() and the visual builder; to see the rendered component
you register it (once) and then it renders for free forever. So "I have to register before the preview shows
anything" is correct behavior, not a defect — budget one cheap registration per component.
The one idea to internalize
A ChainRender app is a component that is compiled to bytes, registered on-chain, and rendered by an eth_call.
The component is the app. Your surrounding code (React, plain HTML, whatever) is a thin shell that calls
renderComponent(...) and displays the on-chain HTML it returns — it does not re-implement the UI.
If your React code fetches data with viem and hand-renders it in JSX, and ChainRender is only used to print a hash on the side, you have not built a ChainRender app. You have built a conventional Web3 dashboard that imports ChainRender. The rendering is happening in your browser, not on-chain.
The correct flow: compile → register → render
import { compileJsx } from "@chainrender/client";
import { renderComponent } from "@chainrender/client/runtime";
// 1. COMPILE — turn your component into on-chain bytes + a content hash.
const { hex, hash } = compileJsx(`
<Box direction="column" variant="surface">
<Text role="heading">Account Balance</Text>
<Slot bind="balance" arg="0xd8dA...6045" />
</Box>
`);
// 2. REGISTER — store the bytes on-chain, ONCE. (Uses your wallet + the ComponentRegistry.)
// After this, the component lives on-chain forever, addressed by `hash`.
// (See the registry helpers; this is a one-time write, not part of rendering.)
// 3. RENDER — anyone renders it for free with a single eth_call. The HTML is computed ON-CHAIN.
const html = await renderComponent({ rpc, router }, hash);
// `html` is the fully-rendered component, including the live balance the Slot resolved on-chain.
document.getElementById("app").innerHTML = html;
Step 3 is the whole point: the balance in that Slot is read and rendered on-chain during the eth_call. You do
not fetch the balance yourself and paste it into JSX — the on-chain interpreter does it, and the result is
identical against any RPC, with no server.
The anti-patterns (what "wrong" looks like)
These are the exact mistakes that turn a ChainRender app into a conventional dashboard. Avoid all of them:
❌ Fetching on-chain data with viem and hand-rendering it
// WRONG — this is a normal React app, not ChainRender.
const bal = await publicClient.getBalance({ address }); // off-chain fetch
return <div>{formatEther(bal)} ETH</div>; // hand-rendered in React
// RIGHT — declare a Slot; the on-chain interpreter reads + renders the balance.
compileJsx(`<Slot bind="balance" arg="${address}" />`); // → register → renderComponent
The difference: the wrong version renders in your browser (nothing on-chain); the right version renders on-chain and
returns HTML. If you're calling publicClient.getBalance/readContract and dropping the result into JSX, you've
bypassed ChainRender entirely.
❌ Compiling but never registering
compileJsx gives you a hash of bytes that do not exist on-chain yet. A hash of un-deployed bytes is not an
on-chain app — it's a preview. A real ChainRender app registers the bytes (step 2) so renderComponent can find
them. If you only ever call compileJsx and display the hash, nothing is on-chain.
❌ Importing the render component but never rendering with it
Importing ChainRenderView / renderComponent and then only displaying compiled.hash as text — with the render
component absent from your returned JSX — means nothing renders. The app becomes a compiler-output viewer with no
live UI and no interactivity. You must actually place <ChainRenderView deployment={...} hash={...} resolvers={...} />
in your JSX (and register the hash on-chain), or call renderComponent and insert the returned HTML. Importing it is
not using it.
❌ Displaying the hash instead of rendering the component
Showing "Content Hash: 0x…" in a sidebar is a nice debug view, but it is not using ChainRender. The app should render the component (step 3) and show that — the actual on-chain-rendered UI — as the primary content.
❌ Reaching for an off-chain indexer/API
Fetching contract source from a block-explorer API, prices from a REST endpoint, or data from any Web2 service
re-introduces the off-chain trust ChainRender exists to remove. If you need on-chain data, read it with a
resolver inside a Slot, so it's rendered on-chain and grades honestly. If you genuinely need an
off-chain source, it must be declared (provenance: "external" / CLIENT_FETCH) so the grade
discloses it — it cannot silently masquerade as on-chain.
The litmus test
Ask these about your app. A genuine ChainRender app answers "yes" to all:
- Is the UI a compiled ChainRender component (not hand-written React/HTML that mirrors it)?
- Are the component's bytes registered on-chain (not just compiled to a hash)?
- Is the UI produced by
renderComponent/ aneth_call(not assembled in the browser)? - Is on-chain data read via
Slot+ resolver (not fetched with viem and pasted in)? - Does the trust grade reflect reality (off-chain dependencies declared, not hidden)?
If any answer is "no," you've built a conventional Web3 app that uses ChainRender, not a ChainRender app. That's a valid thing to build — but it is not on-chain, and it must not be presented as such.
Where React fits (correctly)
React is welcome — as the shell, not the renderer. The correct React usage: a component calls renderComponent,
gets on-chain HTML, and displays it (see Using React and the
@chainrender/client/react helpers). React handles routing, wallet connection, and
chrome; ChainRender handles the actual UI, rendered on-chain. React should never re-implement what the component
renders.
Related
- The Render Pipeline — the compile → register → render path in detail.
- Render a Component — the one-call render, hands-on.
- Resolvers — read on-chain data into a
Slot(instead of fetching with viem). - Trust Grade — why off-chain dependencies must be disclosed, not hidden.
- Overview & Mental Model — the foundation.