Build a Minimal On-Chain Chat App with Somnia Data Streams

In this Tutorial, you’ll build a tiny chat app where messages are published on-chain using the Somnia Data Streams SDK and then read back using a Subscriber pattern (fixed schema ID and publisher). The User Interface updates with simple polling and does not rely on WebSocket.

We will build a Next.js project using app router and Typescript, and create a simple chat schema for messages. Using Somnia Data Streams, we will create a publisher API that writes to the Somnia chain and create a Subscriber API that reads from the Somnia chain by schemaId and publisher. The User Interface will poll new messages every few seconds.

Prerequisites

  • Node.js 18+ and npm

  • A funded Somnia Testnet wallet. Kindly get some from the Faucet.

  • Basic familiarity with TypeScript and Next.js

Project Setup

Create the app by creating a directory where the app will live

npx create-next-app@latest somnia-chat --ts --app --no-tailwind
cd somnia-chat

Install the Somnia Streams and ViemJS dependencies

npm i @somnia-chain/streams viem

Somnia Data Streams is a Typescript SDK with methods that power off-chain reactivity. The application requires the ViemJS provider and wallet methods to enable queries over https embedded in the Somnia Data Streams SDK.

viem is a web3 library for the JS/TS ecosystem that simplifies reading and writing data from the Somnia chain. Importantly, it detaches wallets (and sensitive info) from the SDS SDK Set up the TypeScript environment by running the command:

npm i -D @types/node

Next.js provides a simple, full-stack environment.

Configure environment variables

Create .env.local file. In this example, we use a schemaID that has already been created.

RPC_URL=https://dream-rpc.somnia.network
PRIVATE_KEY=0xYOUR_FUNDED_PRIVATE_KEY

CHAT_SCHEMA_ID=0x80fda840ee61c587e4ca61af66e60ce19f0ac64ad923a25f0b061875c84c20f2
CHAT_PUBLISHER=0xYOUR_WALLET_ADDRESS

The RPC_URL establishes the connection to Somnia Testnet, and the PRIVATE_KEY is used to sign and publish transactions. Note that it is kept server-side only. CHAT_SCHEMA_ID and CHAT_PUBLISHER define what the Subscriber reads.

Chain Configuration

Create a lib folder and define the Somnia Testnet chain. This tells viem which chain we’re on, so clients know the RPC and formatting rules. src/lib/chain.ts

import { defineChain } from 'viem'
export const somniaTestnet = defineChain({
  id: 50312,
  name: 'Somnia Testnet',
  network: 'somnia-testnet',
  nativeCurrency: { name: 'STT', symbol: 'STT', decimals: 18 },
  rpcUrls: {
    default: { http: ['https://dream-rpc.somnia.network'] },
    public:  { http: ['https://dream-rpc.somnia.network'] },
  },
} as const)

SDK Clients

Create public and wallet clients. Public client read-only RPC calls, and the Wallet client publishes transactions signed with your server wallet. We intentionally don’t set up WebSocket clients since we’re using polling for the User Interface. Create a file clients src/lib/clients.ts

import { createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts'
import { somniaTestnet } from './chain'

export function getPublicHttpClient() {
  return createPublicClient({
    chain: somniaTestnet,
    transport: http(rpcUrl),
  })
}

export function getWalletClient() {
  const wallet = createWalletClient({
    account: privateKeyToAccount(need('PRIVATE_KEY') as `0x${string}`),
    chain: somniaTestnet,
    transport: http(rpcUrl),
  })
  return wallet
}

export const publisherAddress = () => getAccount().address

Schema and IDs

Chat schema is the structure of each message. This ordered list of typed fields defines how messages are encoded/decoded on-chain. Create a file chatSchema src/lib/chatSchema.ts

export const chatSchema =
  'uint64 timestamp, bytes32 roomId, string content, string senderName, address sender'

Fixed IDs for reading (Subscriber). The Subscriber always queries the same (schemaId, publisher). This is how we scope reads to a specific message stream. src/lib/chatIds.ts

export const CHAT_SCHEMA_ID =
  (process.env.CHAT_SCHEMA_ID ||
    '0x80fda840ee61c587e4ca61af66e60ce19f0ac64ad923a25f0b061875c84c20f2') as `0x${string}`

export const CHAT_PUBLISHER = process.env.CHAT_PUBLISHER as `0x${string}`

Chat Service

We’ll build chatService.ts in small pieces so it’s easy to follow.

Imports and helpers

import { SDK, SchemaEncoder, zeroBytes32 } from '@somnia-chain/streams'
import { getPublicHttpClient, getWalletClient, publisherAddress } from './clients'
import { waitForTransactionReceipt } from 'viem/actions'
import { toHex, type Hex } from 'viem'
import { chatSchema } from './chatSchema'
import { CHAT_SCHEMA_ID, CHAT_PUBLISHER } from './chatIds'

const encoder = new SchemaEncoder(chatSchema)

const sdk = new SDK({
  public: getPublicHttpClient(),
  wallet: getWalletClient(),
})

SchemaEncoder handles encoding/decoding for the exact schema string.

getSdk(true) attaches the wallet client for publishing; read-only otherwise.

assertHex ensures transaction hashes are hex strings.

Ensure the schema is registered (idempotent)

export async function ensureChatSchema(): Promise<`0x${string}`> {
  const sdk = getSdk()
  const exists = await sdk.streams.isDataSchemaRegistered(CHAT_SCHEMA_ID)
  if (!exists) {
    const tx = await sdk.streams.registerDataSchemas({ id: "chat", schema: chatSchema })
    assertHex(tx, 'Failed to register chat schema')
    await waitForTransactionReceipt(getPublicHttpClient(), { hash: tx })
  }
  return CHAT_SCHEMA_ID
}

If this schema wasn’t registered yet, we register it once. It’s safe to call this before sending the first message.

Publish a message

export async function sendMessage(roomName: string, content: string, senderName: string) {
  const schemaId = await sdk.streams.computeSchemaId(chatSchema)
  const isRegistered = await sdk.streams.isDataSchemaRegistered(schemaId)

  if (!isRegistered) {
    const tx = await sdk.streams.registerDataSchemas({ id: "chat", schema: chatSchema })
    await waitForTransactionReceipt(getPublicHttpClient(), { hash: tx! })
  }

  await ensureChatSchema()

  const roomId = toHex(roomName, { size: 32 })
  const now = Date.now().toString()

  const data = encoder.encodeData([
    { name: 'timestamp',  value: now,                type: 'uint64'  },
    { name: 'roomId',     value: roomId,             type: 'bytes32' },
    { name: 'content',    value: content,            type: 'string'  },
    { name: 'senderName', value: senderName,         type: 'string'  },
    { name: 'sender',     value: publisherAddress(), type: 'address' },
  ])

  const dataId = toHex(`${roomName}-${now}`, { size: 32 })
  const abiItem = parseAbiItem(`event ${CHAT_EVENT_SIG}`)
  const topics = encodeEventTopics({ abi: [abiItem], args: { roomId } })
  const eventData = encodeAbiParameters([], [])

  const tx = await sdk.streams.setAndEmitEvents(
    [{ id: dataId, schemaId, data }],
    [{ id: CHAT_EVENT_ID, argumentTopics: topics.slice(1), data: eventData }]
  )

  await waitForTransactionReceipt(getPublicHttpClient(), { hash: tx! })
  return { txHash: tx }
}
  • We encode fields in the exact order specified in the schema.

  • setAndEmitEvents writes the encoded payload.

The sendMessage function publishes a structured chat message to Somnia Data Streams while simultaneously emitting an event that can be captured in real time by subscribers. It creates a schema encoder for the chat message structure, encodes the message data, and prepares event topics for the ChatMessage event. Then, with a single setAndEmitEvents() transaction, it both stores the message and emits an event on-chain. Once the transaction is confirmed, the function returns the transaction hash, confirming the message was successfully written to the network. Complete code below:

chatService.ts
// src/lib/chatService.ts
import { SDK, SchemaEncoder, zeroBytes32 } from '@somnia-chain/streams'
import { getPublicHttpClient, getWalletClient, publisherAddress } from './clients'
import { waitForTransactionReceipt } from 'viem/actions'
import { toHex, type Hex, parseAbiItem, encodeEventTopics, encodeAbiParameters } from 'viem'
import { chatSchema } from './chatSchema'
import { ensureChatEventSchema, CHAT_EVENT_ID, CHAT_EVENT_SIG } from './chatIds'

const enc = new SchemaEncoder(chatSchema)

const sdk = new SDK({
  public: getPublicHttpClient(),
  wallet: getWalletClient(),
})

// If you have a constant schemaId, you can hardcode it.
// Otherwise: compute + ensure registration once.
let _schemaId: `0x${string}` | null = null
export async function ensureChatSchema(): Promise<`0x${string}`> {
  if (_schemaId) return _schemaId
  const id = await sdk.streams.computeSchemaId(chatSchema)
  const exists = await sdk.streams.isDataSchemaRegistered(id)
  if (!exists) {
    const tx = await sdk.streams.registerSchema(chatSchema, zeroBytes32)
    if (!tx) throw new Error('Failed to register chat schema')
    await waitForTransactionReceipt(getPublicHttpClient(), { hash: tx })
  }
  _schemaId = id
  console.log(id, "here")
  return id
}

export async function sendMessage(roomName: string, content: string, senderName: string) {
  if (!roomName?.trim()) throw new Error('roomName is required')
  if (!content?.trim()) throw new Error('content is required')

  const schemaId = await ensureChatSchema()
  await ensureChatEventSchema()

  const roomId = toHex(roomName, { size: 32 })
  const now = Date.now().toString()

  const data: Hex = enc.encodeData([
    { name: 'timestamp',  value: now,                type: 'uint64'  },
    { name: 'roomId',     value: roomId,             type: 'bytes32' },
    { name: 'content',    value: content,            type: 'string'  },
    { name: 'senderName', value: senderName ?? '',   type: 'string'  },
    { name: 'sender',     value: publisherAddress(), type: 'address' },
  ])

  const dataId = toHex(`${roomName}-${now}`, { size: 32 })

  // Build event topics/data
  const abiItem = parseAbiItem(`event ${CHAT_EVENT_SIG}`)
  const topics = encodeEventTopics({ abi: [abiItem], args: { roomId } })
  const nonIndexed = abiItem.inputs.filter(i => !i.indexed)
  const eventData = nonIndexed.length ? encodeAbiParameters(nonIndexed, []) : '0x'

  // One tx: publish data + emit event
  const tx = await sdk.streams.setAndEmitEvents(
    [{ id: dataId, schemaId, data }],
    [{ id: CHAT_EVENT_ID, argumentTopics: topics.slice(1), data: eventData }]
  )
  if (!tx) throw new Error('Failed to setAndEmitEvents')
  await waitForTransactionReceipt(getPublicHttpClient(), { hash: tx })
console.log(tx, "here too")
  return { txHash: tx }
}

Read Messages

import { SDK } from '@somnia-chain/streams'
import { getPublicHttpClient } from './clients'
import { chatSchema } from './chatSchema'
import { toHex } from 'viem'

/**
 * Fetches and decodes chat messages from a Somnia Data Streams schema.
 * Returns the latest messages, filtered by optional room name.
 */
export async function fetchChatMessages(
  roomName?: string,
  limit = 100
) {
  const sdk = new SDK({ public: getPublicHttpClient() })

  // Compute schemaId from schema definition
  const schemaId = await sdk.streams.computeSchemaId(chatSchema)
  const publisher =
    process.env.NEXT_PUBLIC_PUBLISHER_ADDRESS ||
    '0x0000000000000000000000000000000000000000'

  // Fetch all published data for this schema and publisher
  const resp = await sdk.streams.getAllPublisherDataForSchema(schemaId, publisher)
  const rows: any[][] = Array.isArray(resp) ? resp : []

  // Optional: filter by roomId
  const want = roomName ? toHex(roomName, { size: 32 }).toLowerCase() : null

  const messages = rows
    .filter(Array.isArray)
    .map((row) => {
      const ts = Number(val(row[0]))
      const ms = String(ts).length <= 10 ? ts * 1000 : ts
      const rid = String(row[1]) as `0x${string}`
      return {
        timestamp: ms,
        roomId: rid,
        content: String(row[2]),
        senderName: String(row[3]),
        sender: String(row[4]) as `0x${string}`,
      }
    })
    .filter((m) => (want ? m.roomId.toLowerCase() === want : true))
    .sort((a, b) => a.timestamp - b.timestamp)
    .slice(-limit)

  return messages
}
  • getAllPublisherDataForSchema reads all publisher data for your (schemaId, publisher).

The fetchChatMessages function connects to the Somnia Data Streams SDK using a public client and derives the schema ID from the local chat schema. It then retrieves all data entries published by the specified wallet for that schema, decodes them into readable message objects, and filters them by room if a name is provided. Each message is timestamped, ordered chronologically, and limited to a given count before being returned. The result is a clean, decoded list of on-chain messages. Complete code below:

chatMessages.ts
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import { SDK } from '@somnia-chain/streams'
import { getPublicHttpClient } from './clients'
import { chatSchema } from './chatSchema'
import type { ChatMsg } from './chatQuery'
import { toHex } from 'viem'

const val = (f: any) => f?.value?.value ?? f?.value

/**
 * Fetch chat messages from Somnia Streams (read-only, auto-refresh, cumulative)
 */
export function fetchChatMessages(roomName?: string, limit = 100, refreshMs = 5000) {
  const [messages, setMessages] = useState<ChatMsg[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const timerRef = useRef<NodeJS.Timeout | null>(null)

  const loadMessages = useCallback(async () => {
    try {
      const sdk = new SDK({ public: getPublicHttpClient() })
      const schemaId = await sdk.streams.computeSchemaId(chatSchema)
      const publisher =
        process.env.NEXT_PUBLIC_PUBLISHER_ADDRESS ||
        '0x0000000000000000000000000000000000000000'

      const resp = await sdk.streams.getAllPublisherDataForSchema(schemaId, publisher)
      const rows: any[][] = Array.isArray(resp) ? (resp as any[][]) : []

      const want = roomName
        ? toHex(roomName, { size: 32 }).toLowerCase()
        : null

      const newMessages: ChatMsg[] = []
      for (const row of rows) {
        if (!Array.isArray(row)) continue
        const ts = Number(val(row[0]))
        const ms = String(ts).length <= 10 ? ts * 1000 : ts
        const rid = String(val(row[1])) as `0x${string}`
        if (want && rid.toLowerCase() !== want) continue
        newMessages.push({
          timestamp: ms,
          roomId: rid,
          content: String(val(row[2]) ?? ''),
          senderName: String(val(row[3]) ?? ''),
          sender: String(
            val(row[4]) ?? '0x0000000000000000000000000000000000000000'
          ) as `0x${string}`,
        })
      }

      // Sort new messages chronologically
      newMessages.sort((a, b) => a.timestamp - b.timestamp)

      setMessages((prev) => {
        // Combine existing and new, deduplicating by timestamp+sender+content
        const combined = [...prev, ...newMessages]
        const unique = combined.filter(
          (msg, index, self) =>
            index ===
            self.findIndex(
              (m) =>
                m.timestamp === msg.timestamp &&
                m.sender === msg.sender &&
                m.content === msg.content
            )
        )
        // Keep only the latest N messages
        return unique.slice(-limit)
      })

      setError(null)
    } catch (err: any) {
      console.error('❌ Failed to load chat messages:', err)
      setError(err.message || 'Failed to load messages')
    } finally {
      setLoading(false)
    }
  }, [roomName, limit])

  // Initial load + periodic refresh
  useEffect(() => {
    setLoading(true)
    loadMessages()

    timerRef.current = setInterval(loadMessages, refreshMs)
    return () => {
      if (timerRef.current) clearInterval(timerRef.current)
    }
  }, [loadMessages, refreshMs])

  return { messages, loading, error, reload: loadMessages }
}

API Routes

To parse the data to the NextJS UI, we will create API route files that will enable us to call sendMessage and fetchChatMessages functions

Write Messages Endpoint

src/app/api/send/route.ts

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

import { NextResponse } from 'next/server'
import { sendMessage } from '@/lib/chatService'

export async function POST(req: Request) {
  try {
    const { roomName, content, senderName } = await req.json()
    if (!roomName || !content)
      return NextResponse.json({ error: 'roomName & content required' }, { status: 400 })
    const { txHash } = await sendMessage(roomName, content, senderName ?? '')
    return NextResponse.json({ ok: true, txHash }, { headers: { 'cache-control': 'no-store' } })
  } catch (e: any) {
    return NextResponse.json({ error: e?.message ?? 'send failed' }, { status: 500 })
  }
}

Publishes messages with the server wallet. We validate the input and return the tx hash.

Read Messages Endpoint

src/app/api/messages/route.ts

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

import { NextResponse } from 'next/server'
import { listMessages } from '@/lib/chatService'

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)
  const roomName = searchParams.get('roomName') || undefined
  const limit = Number(searchParams.get('limit') ?? '100')
  try {
    const messages = await listMessages(roomName, limit)
    return NextResponse.json({ room: roomName ?? null, messages }, { headers: { 'cache-control': 'no-store' } })
  } catch (e: any) {
    return NextResponse.json({ error: e?.message ?? 'read failed' }, { status: 500 })
  }
}

Simple read API for the UI. It forwards roomName and limit to the service and returns JSON.

Frontend

Update src/app/page.tsx Connect the Somnia Data Streams logic to a simple frontend. Fetch messages stored on-chain, display them in real time, and send new ones.

Component Setup

'use client'
import { useState } from 'react'
import { useChatMessages } from '@/lib/chatMessages'

export default function Page() {
  const [room, setRoom] = useState('general')
  const [content, setContent] = useState('')
  const [senderName, setSenderName] = useState('Victory')
  const [error, setError] = useState('')

  const { messages, loading, error: fetchError, reload } = useChatMessages(room, 200)

  async function send() {
    try {
      const r = await fetch('/api/send', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ room, content, senderName }),
      })
      const j = await r.json()
      if (!r.ok) throw new Error(j?.error || 'send failed')
      setContent('')
      reload() // refresh after send
    } catch (e: any) {
      setError(e.message || String(e))
    }
  }
  • room, content, and senderName store user input.

  • useChatMessages(room, 200) reads the latest 200 messages from Somnia Data Streams using a read-only SDK instance. The hook automatically polls for new messages every few seconds.

  • The send() function publishes a new message by calling the /api/send endpoint, which writes on-chain using sdk.streams.setAndEmitEvents(). After a message is successfully sent, the input clears and reload() is called to refresh the messages list.

The hook handles loading and error states internally, while the component keeps a separate error state for send failures.

UI Rendering

  return (
    <main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
      <h1>💬 Somnia Streams Chat (read-only)</h1>

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <input
          value={room}
          onChange={(e) => setRoom(e.target.value)}
          placeholder="room"
        />
        <input
          value={senderName}
          onChange={(e) => setSenderName(e.target.value)}
          placeholder="name"
        />
        <button onClick={reload} disabled={loading}>
          Refresh
        </button>
      </div>

      <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
        <input
          style={{ flex: 1 }}
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="Type a message"
        />
        <button onClick={send}>Send</button>
      </div>

      {(error || fetchError) && (
        <div style={{ color: 'crimson', marginBottom: 12 }}>
          Error: {error || fetchError}
        </div>
      )}

      {loading ? (
        <p>Loading messages...</p>
      ) : !messages.length ? (
        <p>No messages yet.</p>
      ) : (
        <ul style={{ paddingLeft: 16 }}>
          {messages.map((m, i) => (
            <li key={i}>
              <small>{new Date(m.timestamp).toLocaleTimeString()} </small>
              <b>{m.senderName || m.sender}</b>: {m.content}
            </li>
          ))}
        </ul>
      )}
    </main>
  )
}
  • The top input fields let users change the chat room or display name, and manually refresh messages if needed.

  • The second input and Send button allow posting new messages.

  • Error messages appear in red if either sending or fetching fails.

  • Below, the app dynamically renders one of three states:

    • “Loading messages…” while fetching.

    • “No messages yet.” if the room is empty.

    • A chat list showing messages with timestamps, names, and content.

Each message represents on-chain data fetched via Somnia Data Streams, fully verified, timestamped, and appended as part of the schema structure. We clear the input and trigger a delayed refresh so the message appears soon after mining.

How It Works

This component bridges the Somnia SDK’s on-chain capabilities with React’s reactive rendering model. Whenever the user sends a message, the SDK publishes it to Somnia Data Streams via the backend /api/send route. Meanwhile, useChatMessages polls the blockchain for updates, decoding structured data stored by the same schema. As a result, each message displayed in the chat window is a verifiable blockchain record, yet the experience feels as fluid and fast as a typical Web2 chat.

+-----------+       +--------------------+      +---------------------+
|  User UI  | --->  |  Next.js API /send | ---> | Somnia Data Streams |
+-----------+       +--------------------+      +---------------------+
      ^                       |                           |
      |                       v                           |
      |             +------------------+                  |
      |             |   Blockchain     |                  |
      |             |  (Transaction)   |                  |
      |             +------------------+                  |
      |                       |                           |
      |<----------------------|                           |
      |     Poll via SDK or Subscribe (useChatMessages)   |
      +---------------------------------------------------+

Complete code below:

page.tsx
'use client'
import { useState } from 'react'
import { useChatMessages } from '@/lib/chatMessages'

export default function Page() {
  const [room, setRoom] = useState('general')
  const [content, setContent] = useState('')
  const [senderName, setSenderName] = useState('Victory')
  const [error, setError] = useState('')

  const { messages, loading, error: fetchError, reload } = useChatMessages(room, 200)

  async function send() {
    try {
      const r = await fetch('/api/send', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ room, content, senderName }),
      })
      const j = await r.json()
      if (!r.ok) throw new Error(j?.error || 'send failed')
      setContent('')
      reload() // refresh after send
    } catch (e: any) {
      setError(e.message || String(e))
    }
  }

  return (
    <main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
      <h1>💬 Somnia Streams Chat (read-only)</h1>

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <input
          value={room}
          onChange={(e) => setRoom(e.target.value)}
          placeholder="room"
        />
        <input
          value={senderName}
          onChange={(e) => setSenderName(e.target.value)}
          placeholder="name"
        />
        <button onClick={reload} disabled={loading}>
          Refresh
        </button>
      </div>

      <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
        <input
          style={{ flex: 1 }}
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="Type a message"
        />
        <button onClick={send}>Send</button>
      </div>

      {(error || fetchError) && (
        <div style={{ color: 'crimson', marginBottom: 12 }}>
          Error: {error || fetchError}
        </div>
      )}

      {loading ? (
        <p>Loading messages...</p>
      ) : !messages.length ? (
        <p>No messages yet.</p>
      ) : (
        <ul style={{ paddingLeft: 16 }}>
          {messages.map((m, i) => (
            <li key={i}>
              <small>{new Date(m.timestamp).toLocaleTimeString()} </small>
              <b>{m.senderName || m.sender}</b>: {m.content}
            </li>
          ))}
        </ul>
      )}
    </main>
  )
}

Run the App

Run the program using the command:

npm run dev

Your app will be LIVE at http://localhost:3000 in your browser

Codebase

https://github.com/emmaodia/somnia-streams-chat-demo

Last updated