18 KiB
| title | sidebar_label | description |
|---|---|---|
| Build a Forum | Build a Forum | Ship decentralized forum experiences over Waku using the OpChan React SDK. |
@opchan/react adds a forum-focused state layer on top of @opchan/core, Wagmi, and the Waku network.
Why OpChan on Waku?
- Relevance-based sorting – content scores combine engagement metrics (upvotes +1, comments +0.5), verification bonuses (ENS authors +25%, wallet-connected +10%), exponential time decay, and moderation penalties to surface quality discussions.
- Forum-first primitives – cells, posts, comments, votes, bookmarks, and moderation helpers.
- Identity blending – anonymous sessions, wallet accounts, ENS verified owners, and call signs share one cache.
- Permission reasoning – UI gates and string reasons with zero custom role logic.
- Delegated signing – short-lived browser keys prevent repetitive wallet prompts while keeping signatures verifiable.
- Local-first sync – IndexedDB hydrates instantly while Waku relays stream live updates in the background.
Prerequisites
- React 18+ with a modern build tool (Vite examples below).
- Node.js 18+ and a package manager (npm or yarn).
- A WalletConnect / Reown project id (
VITE_REOWN_PROJECT_ID) so users can connect wallets. - Basic familiarity with Waku content topics and message reliability concepts.
Quick Links
- Install the SDKs
- Bootstrap the provider
- Hook surface overview
- Common UI patterns
- Authentication flows
- Reference app (collapsible)
- Troubleshooting
Install the SDKs
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
npm install @opchan/react react react-dom buffer
yarn add @opchan/react react react-dom buffer
For Vite + TypeScript projects also install @vitejs/plugin-react, typescript, and @types/react.
1. Bootstrap the provider
Add the Buffer polyfill (still required by many crypto libraries) and wrap your tree with OpChanProvider. The provider internally wires Wagmi, React Query, and the OpChan client to the Waku relay layer. Configure it with the content topic you want to publish under plus the reliable channel id you use for the Waku Reliable Message Channel.
Pick your own identifiers. The content topic and reliable channel ID form the address of your forum data. The sample values below point to the public https://opchan.app/ deployment; leave them as-is only if you want to read/write the shared data graph. For a separate app, use two unique values (for example /myapp/1/forum/proto and myapp-forum) and reuse them everywhere you run the OpChan SDK so all clients join the same silo.
import { createRoot } from 'react-dom/client';
import { OpChanProvider } from '@opchan/react';
import { Buffer } from 'buffer';
import App from './App';
if (!(window as any).Buffer) {
(window as any).Buffer = Buffer;
}
const config = {
// These defaults stream from the hosted https://opchan.app/ deployment;
// change both values to point the SDK at your own isolated data silo.
wakuConfig: {
contentTopic: '/opchan/1/messages/proto',
reliableChannelId: 'opchan-messages',
},
reownProjectId: import.meta.env.VITE_REOWN_PROJECT_ID,
};
createRoot(document.getElementById('root')!).render(
<OpChanProvider config={config}>
<App />
</OpChanProvider>,
);
Understanding reownProjectId:
The reownProjectId is required by WalletConnect/Reown (formerly WalletConnect) to identify your application when users connect their wallets. Here's how it works:
-
Why it's needed: WalletConnect/Reown uses project IDs to manage application registrations, track usage, and provide secure wallet connection services. Without a valid project ID, wallet connection attempts will fail.
-
How the value flows:
- Set
VITE_REOWN_PROJECT_IDin your.envfile (e.g.,VITE_REOWN_PROJECT_ID=your-project-id-here) - Vite injects it at build time via
import.meta.env.VITE_REOWN_PROJECT_ID - The value is passed to
OpChanProvider'sconfigobject OpChanProviderinternally forwards it to Wagmi's configuration, which initializes WalletConnect/Reown connectors- When users click "Connect Wallet", WalletConnect/Reown uses this project ID to establish the connection
- Set
-
Getting a project ID: Register your application at cloud.reown.com to receive a free project ID. This ID uniquely identifies your app in the WalletConnect network.
:::tip
Keep secrets out of the bundle: reference the Reown id via an environment variable (VITE_REOWN_PROJECT_ID) rather than hard-coding it.
:::
2. Understand the hook surface
Most apps can reach for the high-level useForum() hook and destructure the four core stores it exposes.
import { useForum } from '@opchan/react';
const { user, content, permissions, network } = useForum();
For bespoke logic, drop down to the individual hooks:
| Hook | Purpose | Typical actions |
|---|---|---|
useAuth() |
Manage sessions, ENS verification, call signs, and delegation | connect(), startAnonymous(), delegate('7days'), updateProfile() |
useContent() |
Read/write cells, posts, comments, votes, bookmarks | createPost(), vote(), moderate.post(), pending.isPending(id) |
usePermissions() |
Query derived capabilities and friendly denial reasons | canCreateCell, canModerate(cellId), check('canPost') |
useNetwork() |
Reflect Waku connection status and hydration lifecycle | isConnected, statusMessage, refresh() |
useUserDisplay(address) |
Resolve ENS + call-sign metadata for any address | displayName, ensAvatar, verificationStatus |
useUIState(key, defaultValue, category?) |
Persist UI state to IndexedDB | [value, setValue] pair scoped by key |
useEthereumWallet() / useClient() |
Advanced access to Wagmi connectors or the raw OpChanClient |
signMessage(), direct database interactions |
Sample snippets
import { useAuth } from '@opchan/react';
export function AuthButton() {
const { currentUser, connect, startAnonymous, disconnect } = useAuth();
if (currentUser) return <button onClick={disconnect}>Disconnect</button>;
return (
<div className="space-y-2">
<button onClick={connect}>Connect Wallet</button>
<button onClick={startAnonymous}>Continue Anonymously</button>
</div>
);
}
import { useContent } from '@opchan/react';
export function CreatePostForm({ cellId }: { cellId: string }) {
const { createPost, pending } = useContent();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const handleSubmit = async () => {
const post = await createPost({ cellId, title, content: body });
if (post) {
setTitle('');
setBody('');
}
};
return (
<form onSubmit={(event) => { event.preventDefault(); handleSubmit(); }}>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
<textarea value={body} onChange={(event) => setBody(event.target.value)} />
<button type="submit" disabled={pending.isPending(cellId)}>
{pending.isPending(cellId) ? 'Syncing…' : 'Publish'}
</button>
</form>
);
}
import { usePermissions } from '@opchan/react';
export function PostActions() {
const permissions = usePermissions();
return (
<div className="space-y-2">
<button disabled={!permissions.canVote}>
{permissions.canVote ? 'Upvote' : permissions.reasons.vote}
</button>
{!permissions.canCreateCell && (
<p className="text-sm text-gray-500">{permissions.reasons.createCell}</p>
)}
</div>
);
}
import { useNetwork } from '@opchan/react';
export function NetworkIndicator() {
const { isConnected, statusMessage, refresh } = useNetwork();
return (
<div className="flex items-center gap-2">
<span>{statusMessage}</span>
{!isConnected && <button onClick={refresh}>Reconnect</button>}
</div>
);
}
import { useUserDisplay } from '@opchan/react';
export function AuthorBadge({ author }: { author: string }) {
const { displayName, callSign, ensName, verificationStatus } = useUserDisplay(author);
return (
<span>
{displayName}
{callSign && ` (#${callSign})`}
{ensName && ` (${ensName})`}
{verificationStatus === 'ens-verified' && ' ✅'}
</span>
);
}
3. Compose UX patterns
Anonymous-first onboarding
function PostPage() {
const { user, permissions } = useForum();
return (
<>
{!user.currentUser && (
<div className="space-x-2">
<button onClick={user.connect}>Connect Wallet</button>
<button onClick={user.startAnonymous}>Continue Anonymously</button>
</div>
)}
{permissions.canComment && <CommentForm />}
{user.verificationStatus === 'anonymous' && !user.currentUser?.callSign && (
<CallSignPrompt />
)}
</>
);
}
Permission-gated cells
function CellActions() {
const { permissions } = useForum();
const check = permissions.check('canCreateCell');
return check.allowed ? <CreateCellButton /> : <p>{check.reason}</p>;
}
Additional reusable patterns:
- Real-time lists –
postsByCell+pending.isPending(id)to show optimistic badges. - Identity chips –
useUserDisplay(address)for ENS/call-sign aware avatars. - Pending badges elsewhere – subscribe to
pending.onChange()to reflect sync status in any component.
4. Authentication flows
Anonymous path
await startAnonymous()- Immediately interact (posts, comments, votes).
- Optional:
updateProfile({ callSign: 'my-handle' }). - Later call
connect()to attach a wallet without losing history.
Wallet + ENS path
await connect().await verifyOwnership()to upgrade to an ENS verified user.await delegate('7days')or'30days'to mint a browser key.- Use the full forum surface (create cells, moderate, vote).
5. Types and configuration
type User = {
address: string; // wallet 0x… or anonymous session UUID
displayName: string;
displayPreference: EDisplayPreference;
verificationStatus: EVerificationStatus;
callSign?: string;
ensName?: string;
ensAvatar?: string;
lastChecked?: number;
};
enum EVerificationStatus {
ANONYMOUS = 'anonymous',
WALLET_UNCONNECTED = 'wallet-unconnected',
WALLET_CONNECTED = 'wallet-connected',
ENS_VERIFIED = 'ens-verified',
}
interface OpChanProviderProps {
config: {
wakuConfig?: {
contentTopic?: string;
reliableChannelId?: string;
};
reownProjectId?: string;
};
children: React.ReactNode;
}
6. Reference layout
View the end-to-end example
The following snippets combine the concepts above into a minimal but complete app shell.
import { createRoot } from 'react-dom/client';
import { OpChanProvider } from '@opchan/react';
import { Buffer } from 'buffer';
import App from './App';
if (!(window as any).Buffer) {
(window as any).Buffer = Buffer;
}
createRoot(document.getElementById('root')!).render(
<OpChanProvider config={{
wakuConfig: {
contentTopic: '/opchan/1/messages/proto',
reliableChannelId: 'opchan-messages',
},
reownProjectId: import.meta.env.VITE_REOWN_PROJECT_ID || 'demo-project-id',
}}>
<App />
</OpChanProvider>
);
import { useForum } from '@opchan/react';
export default function App() {
const { user, network } = useForum();
if (!network.isHydrated) {
return <div>Loading…</div>;
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<Header />
<main className="container mx-auto p-4">
{!user.currentUser ? <AuthPrompt /> : <ForumInterface />}
</main>
</div>
);
}
import { useAuth } from '@opchan/react';
export function AuthPrompt() {
const { connect, startAnonymous } = useAuth();
return (
<div className="space-y-4 text-center">
<h1 className="text-2xl font-bold">Welcome to OpChan</h1>
<p className="text-gray-400">Choose how you would like to participate:</p>
<div className="space-y-2">
<button onClick={connect} className="w-full rounded bg-blue-600 px-4 py-2">Connect Wallet</button>
<button onClick={startAnonymous} className="w-full rounded bg-gray-600 px-4 py-2">Continue Anonymously</button>
</div>
</div>
);
}
import { useAuth } from '@opchan/react';
export function Header() {
const { currentUser, disconnect, verificationStatus } = useAuth();
return (
<header className="bg-gray-800 p-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">OpChan</h1>
{currentUser && (
<div className="flex items-center gap-4 text-sm">
<span>
{currentUser.displayName}
{verificationStatus === 'anonymous' && ' (Anonymous)'}
{verificationStatus === 'ens-verified' && ' (ENS)'}
</span>
<button onClick={disconnect} className="text-gray-400 hover:text-white">Disconnect</button>
</div>
)}
</div>
</header>
);
}
import { useContent, usePermissions } from '@opchan/react';
export function ForumInterface() {
const { cells, posts, createPost } = useContent();
const { canPost, canCreateCell } = usePermissions();
return (
<div className="space-y-6">
<section>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Cells</h2>
{canCreateCell && <button className="rounded bg-green-600 px-4 py-2">Create Cell</button>}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{cells.map((cell) => (
<CellCard key={cell.id} cell={cell} />
))}
</div>
</section>
<section>
<h3 className="text-lg font-semibold">Recent Posts</h3>
<div className="space-y-2">
{posts.slice(0, 10).map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
{canPost && cells[0] && (
<button
onClick={() => createPost({ cellId: cells[0].id, title: 'Hello', content: 'World' })}
className="rounded bg-blue-600 px-4 py-2"
>
Create Post in first cell
</button>
)}
</div>
);
}
import { useContent } from '@opchan/react';
export function CellCard({ cell }) {
const { postsByCell } = useContent();
const cellPosts = postsByCell[cell.id] || [];
return (
<div className="rounded-lg bg-gray-800 p-4">
<h3 className="font-semibold">{cell.name}</h3>
<p className="mb-2 text-sm text-gray-400">{cell.description}</p>
<div className="text-xs text-gray-500">{cellPosts.length} posts</div>
</div>
);
}
import { useUserDisplay } from '@opchan/react';
export function PostCard({ post }) {
const { displayName, callSign, ensName } = useUserDisplay(post.author);
return (
<div className="rounded bg-gray-800 p-3">
<div className="mb-2 flex items-start justify-between text-sm">
<span>
{displayName}
{callSign && ` (#${callSign})`}
{ensName && ` (${ensName})`}
</span>
<span className="text-xs text-gray-500">{new Date(post.timestamp).toLocaleDateString()}</span>
</div>
<h4 className="font-medium">{post.title}</h4>
<p className="mt-1 text-sm text-gray-400">{post.content}</p>
</div>
);
}
7. Troubleshooting
"useClient must be used within ClientProvider"
Hooks must live under <OpChanProvider>.
function App() {
return (
<OpChanProvider config={config}>
<MainApp />
</OpChanProvider>
);
}
function MainApp() {
const { currentUser } = useAuth();
return <div>Hello</div>;
}
Wallet connection fails
Set reownProjectId inside the provider config (WalletConnect / Reown requires a project id).
Buffer is not defined
Add the Buffer polyfill snippet before rendering (see step 1).
Anonymous users lose permissions after setting a call sign
Ensure your helpers map the ANONYMOUS status correctly and that updateProfile preserves the existing verificationStatus.
Wallet disconnect clears anonymous sessions
Check that your disconnect UX only clears browser keys for the current mode.
9. Quick reference checklist
- Install
@opchan/react+@opchan/core. - Polyfill
Buffer, wrap the tree withOpChanProvider, and passwakuConfig+reownProjectId. - Use
useForum()for day-to-day UI; drop down to other hooks when necessary. - Gate every action through
usePermissions()(surfacereasonsin the UI). - Render identities through
useUserDisplay()so ENS + call signs stay in sync. - Monitor
network.isHydratedbefore rendering expensive components. - Show optimistic UI feedback via
pending.isPending(id)hooks.
License
MIT — Built with ❤️ for decentralized communities.