Build a chat app on 0G
A real-time chat where every message is persisted to 0G Storage and the
on-chain MessagePosted event log is the source of truth. By the end you'll
have a Next.js app that lets you type a message, persists it, and renders the
running history reactively as other writers post.
This tutorial uses the chat template under the hood — but instead of just
reading the README, you'll understand the design choices and graduate to a
production-shaped extension.
What you're building
┌────────────────────────────────────────┐
│ 0Gkit chat │
├────────────────────────────────────────┤
│ alice Hi everyone │
│ bob Hi alice! │
│ alice How is the testnet treating │
│ you? │
│ │
│ [ type a message … ] [Send] │
└────────────────────────────────────────┘
Each row is one MessagePosted event on chain → one root in storage → one
decoded body. Reorg-safe via useEvent: if a chain reorg drops the block
the message was in, the row disappears automatically.
Prerequisites
- Node 20.10 or newer.
- A funded
galileotestnet key. Get one atfaucet.0g.ai. - About 10 minutes.
Scaffold
npm create 0gkit-app@latest my-chat -- --template chat
cd my-chat
cp .env.example .env
Open .env and paste your testnet key into PRIVATE_KEY. Leave
NETWORK=galileo unless you're running a local devnet.
Deploy the MessageRegistry contract
The contract is 30 lines of Solidity:
// contracts/MessageRegistry.sol
pragma solidity ^0.8.20;
contract MessageRegistry {
event MessagePosted(address indexed author, bytes32 root, uint256 ts);
function post(bytes32 root, uint256 ts) external {
emit MessagePosted(msg.sender, root, ts);
}
}
It does one thing: emit an event tying an author to a storage root. No on-chain text, no balance, no permission checks — those add up to gas you don't need. The text lives in 0G Storage, the chain just bookmarks it.
Deploy options, in order of friction:
- Local devnet (zero setup): run
0g devin another terminal. It boots an Anvil node and deploys the registry automatically. Copy the printed address intoNEXT_PUBLIC_MESSAGE_REGISTRY_ADDRESS. - Galileo testnet via Foundry:
Paste the returnedforge create --rpc-url https://evmrpc-testnet.0g.ai \ --private-key $PRIVATE_KEY \ contracts/MessageRegistry.sol:MessageRegistryDeployed to:address into the env.
Walk the code
1. The wire format — lib/message.ts
export type Message = { v: 1; author: `0x${string}`; ts: number; body: string };
const MAX_BODY = 4 * 1024; // 4 KiB
export function encodeMessage(m: Message): Uint8Array {
if (m.body.length > MAX_BODY) throw new Error("body too long");
return new TextEncoder().encode(JSON.stringify(m));
}
export function decodeMessage(bytes: Uint8Array): Message {
const m = JSON.parse(new TextDecoder().decode(bytes));
if (m.v !== 1) throw new Error("unknown message version");
return m;
}
A versioned JSON envelope is the right call here. The v: 1 field
future-proofs the codec: when you change the format you bump the version
and write a decoder that branches on it. Old roots stay readable forever.
2. Server-side write — app/api/post/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Storage } from "@foundryprotocol/0gkit-storage";
import { createTypedContract } from "@foundryprotocol/0gkit-contracts";
import { encodeMessage } from "@/lib/message";
import { registryAbi } from "@/lib/abi";
const storage = new Storage({
network: process.env.NETWORK as "galileo",
privateKey: process.env.PRIVATE_KEY!,
});
const registry = createTypedContract({
abi: registryAbi,
address: process.env.NEXT_PUBLIC_MESSAGE_REGISTRY_ADDRESS as `0x${string}`,
signer: { privateKey: process.env.PRIVATE_KEY! },
network: process.env.NETWORK as "galileo",
});
export async function POST(req: NextRequest) {
const { author, body } = await req.json();
const ts = Math.floor(Date.now() / 1000);
const bytes = encodeMessage({ v: 1, author, ts, body });
const { root } = await storage.upload(bytes);
const { txHash } = await registry.write.post([root, BigInt(ts)]);
return NextResponse.json({ root, txHash, ts });
}
export async function GET(req: NextRequest) {
const root = req.nextUrl.searchParams.get("root") as `0x${string}`;
const bytes = await storage.download(root);
return new Response(bytes, {
headers: { "Content-Type": "application/octet-stream" },
});
}
Two endpoints sharing one signer. The server holds the private key so the
browser never sees it. Production refinement: rate-limit by author,
authenticate the post call against a real wallet signature, charge per
message.
3. The UI — app/page.tsx
"use client";
import { useEvent } from "@foundryprotocol/0gkit-react";
import { registryAbi } from "@/lib/abi";
import { decodeMessage } from "@/lib/message";
export default function Page() {
const events = useEvent({
contract: {
abi: registryAbi,
address: process.env.NEXT_PUBLIC_MESSAGE_REGISTRY_ADDRESS as `0x${string}`,
},
event: "MessagePosted",
});
// events is an array; on reorg, rolled-back blocks are filtered out
// automatically.
return (
<main>
<ul>
{events.map((e) => (
<MessageRow key={e.blockHash + e.logIndex} ev={e} />
))}
</ul>
<Composer />
</main>
);
}
function MessageRow({ ev }: { ev: { args: { root: `0x${string}` } } }) {
const [body, setBody] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/post?root=${ev.args.root}`)
.then((r) => r.arrayBuffer())
.then((buf) => decodeMessage(new Uint8Array(buf)))
.then((m) => setBody(m.body));
}, [ev.args.root]);
return <li>{body ?? "loading…"}</li>;
}
useEvent is the magic here. It subscribes to a single contract event
through the indexer's polling loop, dedupes against the current cursor, and
filters out rolled-back blocks on reorg automatically. You don't write
the reorg handler — it's the indexer's job.
4. Provider plumbing — app/providers.tsx
"use client";
import { ZeroGIndexerProvider } from "@foundryprotocol/0gkit-react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ZeroGIndexerProvider
network={process.env.NEXT_PUBLIC_NETWORK as "galileo"}
pollIntervalMs={1500}
reorgDepth={32}
>
{children}
</ZeroGIndexerProvider>
);
}
One provider per app. Every useEvent / useLogs hook in the tree shares
its single polling loop. reorgDepth: 32 means we keep the last 32 block
hashes for divergence detection — generous for testnet, conservative
enough that detection is cheap.
Run it
pnpm dev
Open http://localhost:3000. The first message
takes ~3 seconds round-trip (storage upload + on-chain confirm). Subsequent
messages appear within the poll interval. Try opening two tabs as different
authors — both see each other's messages without a manual refresh.
Extend it: per-room channels
Add a room field to the event signature so users can chat in distinct
rooms:
event MessagePosted(
address indexed author,
bytes32 indexed room,
bytes32 root,
uint256 ts
);
function post(bytes32 room, bytes32 root, uint256 ts) external {
emit MessagePosted(msg.sender, room, root, ts);
}
Update useEvent to filter by indexed topic:
const events = useEvent({
contract: { abi: registryAbi, address: REGISTRY },
event: "MessagePosted",
args: { room: keccak256(toUtf8Bytes(roomName)) }, // filtered server-side
});
Because room is indexed, the chain stores it as a topic — filtering by
topic is free at the RPC layer. Don't index body; that defeats the
storage offload.
Production hardening
Move the signer off the server
The server-key model is fine for a tutorial. In production, the user signs their own post call:
- Add
@foundryprotocol/0gkit-wallet-reactto the client bundle. - Use a wallet hook to obtain a viem
WalletClient. - Call
createTypedContract({ ..., signer: walletClient })client-side. - The storage upload still needs a server-side service key (storage uploads fund segments — you don't want every user to hold storage gas), or you can move uploads to your own gateway and charge users out-of-band.
Rate limiting and abuse
- Rate-limit
/api/postby author (Upstash, Vercel KV, anything). One message per user per second is a generous bound. - Cap
body.lengthserver-side before the storage call. Storage gas is cheap but not free. - Reject roots whose decoded
authordoesn't match a signed payload from the browser. Otherwise anyone can impersonate.
Durable uploads
A 50-MB attachment shouldn't block a request handler. Move uploads onto
@foundryprotocol/0gkit-jobs:
// app/api/post/route.ts becomes:
const jobId = await runner.enqueue({
name: "post-message",
input: { author, body },
});
return NextResponse.json({ jobId, status: "queued" });
Wire a webhook on the runner to push the final root to your browser via SSE / websocket / poll. See the durable jobs concept guide for the full pattern.
Observability
Instrument every call so you can see latency, cost, and failure mode in your trace backend:
import { instrument0g } from "@foundryprotocol/0gkit-observability";
await instrument0g({ serviceName: "chat-server", exporter: { kind: "otlp" } });
Every storage.upload and contract.write.post then emits a span tagged
with 0gkit.op, 0gkit.size_bytes, 0gkit.gas_native, etc. See the
observability concept and the Honeycomb
guide.
What you built
You have:
- A chat where every message has a Merkle root + an on-chain event.
- A reorg-safe UI that doesn't need polling code.
- A scalable storage path that doesn't congest the chain.
Production handoff: rate-limit, move signing client-side, durable uploads, spans. Each of these is a one-day project, not a rewrite.
See also
@foundryprotocol/0gkit-indexerreference.useEventanduseLogshooks.@foundryprotocol/0gkit-contracts— typed contracts and codegen.- Durable jobs.
- Observability.