Building Minimum Viable Social Dapp
This tutorial will guide you through building a minimum viable social dapp (a decentralized Twitter/X) with WeaveDB.
Create Project
create a db project using the web-cli create
command.
npx wdb-cli create social && social
Now you have db
directory to put config files, and test
directory to write tests.
Replace /test/main.test.js
with the following skelton, which initializes a database with the config files in db
.
import assert from "assert"
import { describe, it } from "node:test"
import { acc } from "wao/test"
import { DB } from "wdb-sdk"
import { init } from "./utils.js"
const actor1 = acc[1]
const actor2 = acc[2]
describe("Social Dapp", () => {
it("should post notes", async () => {
const { id, db, q: mem } = await init()
const a1 = new DB({ jwk: actor1.jwk, id, mem })
const a2 = new DB({ jwk: actor2.jwk, id, mem })
})
})
Now you have 3 clients, the DB owner (db
) and 2 users (a1
and a2
).
Tests can be run with yarn test-all
.
yarn test-all
Create Notes with Schema
and Auth
What is truly magical about WeaveDB is that you only need JSON configuration files. No smart contracts required to build any complex applications. The DB itself is as powerful as any smart contract, thanks to FPJSON, code as data.
We are going to borrow as much vocabulary as possible from Activity Streams and Activity Vocabulary, which are web standard protocols for social apps.
Text-based posts are called notes, and users are called actors. Let's create a schema for notes
using JSON Schema.
export default {
notes: {
type: "object",
required: ["id", "actor", "content", "published"],
properties: {
id: { type: "string" },
actor: { type: "string", pattern: "^[a-zA-Z0-9_-]{43}quot; },
content: { type: "string", minLength: 1, maxLength: 140 },
published: { type: "integer" },
},
additionalProperties: false,
}
}
Now we can have a note like the following.
{
"id": "A",
"actor": "Tbun4iRRQW93gUiSAmTmZJ2PGI-_yYaXsX69ETgzSRE",
"content": "Hello, World!",
"published": 1757588601
}
id
is auto-incremented starting from A
, actor
is the signer
of the query, and published
is auto-asigned by the auth rules so users cannot set an arbitrary timestamp. The only thing users should specify is content
.
Create a custom query type called add:note
to achieve this.
export default {
notes: [
[
"add:note",
[
["fields()", ["*content"]],
["mod()", { id: "$doc", actor: "$signer", published: "$ts" }],
["allow()"],
],
],
],
}
fields()
can specify required fields from users, and *
makes content
mandatory.
mod()
modifies the uploaded data by adding values to id
, actor
, and published
.
Finally, allow()
gives you the access to write the transformed data to the database.
With these schema and rules, users can now add notes.
await a1.set("add:note", { content: "Hello, World!" }, "notes")
Update the test file.
import assert from "assert"
import { describe, it } from "node:test"
import { acc } from "wao/test"
import { DB } from "wdb-sdk"
import { init } from "./utils.js"
const actor1 = acc[1]
const actor2 = acc[2]
describe("Social Dapp", () => {
it("should post notes", async () => {
const { id, db, q: mem } = await init()
const a1 = new DB({ jwk: actor1.jwk, id, mem })
const a2 = new DB({ jwk: actor2.jwk, id, mem })
await a1.set("add:note", { content: "Hello, World!" }, "notes")
await a2.set("add:note", { content: "GM, World!" }, "notes")
console.log(await db.get("notes"))
})
})
Create Likes and Add Multi-Field Indexes
Now, let's add the good old like feature. Users can like notes, and notes will be sorted by like counts.
We will add likes
dir with actor
, object
, and published
. object
is the note id
actor
likes.
export default {
notes: {
type: "object",
required: ["id", "actor", "content", "published"],
properties: {
id: { type: "string" },
actor: { type: "string", pattern: "^[a-zA-Z0-9_-]{43}quot; },
content: { type: "string", minLength: 1, maxLength: 140 },
published: { type: "integer" },
},
additionalProperties: false,
},
likes: {
type: "object",
required: ["actor", "object", "published"],
properties: {
actor: { type: "string", pattern: "^[a-zA-Z0-9_-]{43}quot; },
object: { type: "string" },
published: { type: "integer" },
},
additionalProperties: false,
},
}
In the auth rules, we should check if the like already exists with the same actor
and the same object
.
Create a custom query called add:like
.
export default {
notes: [
[
"add:note",
[
["fields()", ["*content"]],
["mod()", { id: "$doc", actor: "$signer", published: "$ts", likes: 0 }],
["allow()"],
],
],
],
likes: [
[
"add:like",
[
["fields()", ["*object"]],
["mod()", { actor: "$signer", published: "$ts" }],
[
"=$likes",
[
"get()",
[
"likes",
["actor", "==", "$signer"],
["object", "==", "$req.object"],
],
],
],
["=$ok", ["o", ["equals", 0], ["length"], "$likes"]],
["denyifany()", ["!$ok"]],
["allow()"],
],
],
],
}
get()
queries where actor
is the $signer
and object
is $req.object
. This query requires a multi-field index to sort by actor
first, then by object
. So let's define the index.
export default {
likes: [[["actor"], ["object"]]],
}
Now users can like notes.
await a1.set("add:like", { object: noteID }, "likes")
Update the tests.
import assert from "assert"
import { describe, it } from "node:test"
import { acc } from "wao/test"
import { DB } from "wdb-sdk"
import { init } from "./utils.js"
const actor1 = acc[1]
const actor2 = acc[2]
describe("Social Dapp", () => {
it("should post notes", async () => {
const { id, db, q: mem } = await init()
const a1 = new DB({ jwk: actor1.jwk, id, mem })
const a2 = new DB({ jwk: actor2.jwk, id, mem })
await a1.set("add:note", { content: "Hello, World!" }, "notes")
await a2.set("add:note", { content: "GM, World!" }, "notes")
const notes = await db.get("notes", ["published", "desc"])
await a1.set("add:like", { object: notes[0].id }, "likes")
await a2.set("add:like", { object: notes[1].id }, "likes")
console.log(await db.get("likes"))
})
})
Count Likes with Triggers
Now, we can add likes
field to notes
to count up the likes.
export default {
notes: {
type: "object",
required: ["id", "actor", "content", "published", "likes"],
properties: {
id: { type: "string" },
actor: { type: "string", pattern: "^[a-zA-Z0-9_-]{43}quot; },
content: { type: "string", minLength: 1, maxLength: 140 },
published: { type: "integer" },
likes: { type: "integer" },
},
additionalProperties: false,
},
likes: {
type: "object",
required: ["actor", "object", "published"],
properties: {
actor: { type: "string", pattern: "^[a-zA-Z0-9_-]{43}quot; },
object: { type: "string" },
published: { type: "integer" },
},
additionalProperties: false,
},
}
But how do we increment likes
? It turned out that we can use triggers to execute data transformations on data changes.
export default {
likes: [
{
key: "inc_likes",
on: "create",
fn: [
["update()", [{ likes: { _$: ["inc"] } }, "notes", "$after.object"]],
],
},
],
}
This trigger will increment likes
of $after.object
in the notes
dir, when a new like
is created.
Update the test file, and see the likes
counts go up.
import assert from "assert"
import { describe, it } from "node:test"
import { acc } from "wao/test"
import { DB } from "wdb-sdk"
import { init } from "./utils.js"
const actor1 = acc[1]
const actor2 = acc[2]
describe("Social Dapp", () => {
it("should post notes", async () => {
const { id, db, q: mem } = await init()
const a1 = new DB({ jwk: actor1.jwk, id, mem })
const a2 = new DB({ jwk: actor2.jwk, id, mem })
await a1.set("add:note", { content: "Hello, World!" }, "notes")
await a2.set("add:note", { content: "GM, World!" }, "notes")
const notes = await db.get("notes", ["published", "desc"])
await a1.set("add:like", { object: notes[0].id }, "likes")
await a2.set("add:like", { object: notes[1].id }, "likes")
const notes2 = await db.get("notes", ["published", "desc"])
assert.equals(notes2[0].likes, 1)
assert.equals(notes2[1].likes, 1)
})
})
Test and Deploy DB
Finally, run the tests.
yarn test-all
Make sure you are running a local rollup node and a HyperBEAM node, then have .wallet.json
in the app root directory.
Let's deploy the DB.
yarn deploy --wallet .wallet.json
If you go to http://localhost:6364/status, you will see your newly deployed DB is listed under processes
. Save the database ID. You will need it later.
Build Frontend Dapp
We are going to build the simplest social app ever using NextJS!
For simplicity, use the old pages
structure insted of apps
.
npx create-next-app myapp && cd myapp
import { useRef, useEffect, useState } from "react"
import { DB } from "wdb-sdk"
export default function Home() {
const [notes, setNotes] = useState([])
const [likes, setLikes] = useState([])
const [slot, setSlot] = useState(null)
const [body, setBody] = useState("")
const [likingNotes, setLikingNotes] = useState({})
const [showToast, setShowToast] = useState(false)
const db = useRef()
const getNotes = async () => {
const _notes = await db.current.cget("notes", ["published", "desc"], 10)
const ids = _notes.map(v => v.id)
const _likes = await db.current.get("likes", ["object", "in", ids])
setNotes(_notes)
setLikes(_likes.map(v => v.object))
}
const handleLike = async post => {
if (likingNotes[post.id]) return
setLikingNotes(prev => ({ ...prev, [post.id]: true }))
try {
if (window.arweaveWallet) {
await window.arweaveWallet.connect([
"ACCESS_ADDRESS",
"SIGN_TRANSACTION",
])
}
const res = await db.current.set(
"add:like",
{ object: post.data.id },
"likes",
)
const { success, result } = res
if (success) {
setSlot(result.result.i)
await getNotes()
} else {
alert("Failed to like post!")
}
} catch (error) {
console.error("Error liking post:", error)
alert("Something went wrong!")
} finally {
setLikingNotes(prev => ({ ...prev, [post.id]: false }))
}
}
const handlePost = async () => {
if (body.length <= 140 && body.trim().length > 0) {
try {
if (window.arweaveWallet) {
await window.arweaveWallet.connect([
"ACCESS_ADDRESS",
"SIGN_TRANSACTION",
])
}
const res = await db.current.set("add:note", { content: body }, "notes")
const { success, result } = res
if (success) {
setSlot(result.result.i)
setShowToast(true)
setBody("")
await getNotes()
// Auto-close removed - manual close only
} else {
alert("something went wrong!")
}
} catch (error) {
console.error("Error posting:", error)
alert("Something went wrong!")
}
}
}
const formatTime = date => {
const now = new Date()
const posted = new Date(date)
const diffInMinutes = Math.floor((now - posted) / (1000 * 60))
if (diffInMinutes < 1) return "now"
if (diffInMinutes < 60) return `${diffInMinutes}m`
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h`
return `${Math.floor(diffInMinutes / 1440)}d`
}
const truncateAddress = address => {
if (!address) return "Unknown"
if (address.length <= 16) return address
return `${address.slice(0, 8)}...${address.slice(-8)}`
}
useEffect(() => {
void (async () => {
db.current = new DB({
id: process.env.NEXT_PUBLIC_DB_ID,
url: process.env.NEXT_PUBLIC_RU_URL,
})
await getNotes()
})()
}, [])
return (
<>
<div className="app-container">
{/* Header */}
<header className="app-header">
<div className="logo">W</div>
<a
href={`${process.env.NEXT_PUBLIC_SCAN_URL}/db/${process.env.NEXT_PUBLIC_DB_ID}?url=${process.env.NEXT_PUBLIC_RU_URL}`}
target="_blank"
rel="noopener noreferrer"
className="scan-link"
>
Scan
</a>
</header>
{/* Composer */}
<div className="composer">
<div className="composer-avatar">
<div className="avatar">WDB</div>
</div>
<div className="composer-main">
<textarea
className="composer-input"
placeholder="What's happening?"
value={body}
onChange={e => {
if (e.target.value.length <= 140) {
setBody(e.target.value)
}
}}
maxLength={140}
/>
<div className="composer-footer">
<span
className={`char-count ${body.length > 120 ? "warning" : ""} ${body.length === 140 ? "danger" : ""}`}
>
{140 - body.length}
</span>
<button
className="post-btn"
disabled={body.trim().length === 0}
onClick={handlePost}
>
Post
</button>
</div>
</div>
</div>
{/* Feed */}
<div className="feed">
{notes.map(post => (
<article key={post.id} className="post">
<div className="post-avatar">
<div className="avatar">
{post.data.actor?.slice(0, 2).toUpperCase() || "??"}
</div>
</div>
<div className="post-main">
<div className="post-header">
<span className="post-author">{post.data.actor}</span>
<span className="post-time">
{formatTime(post.data.published)}
</span>
</div>
<div className="post-content">{post.data.content}</div>
<div className="post-actions">
<button
className={`like-btn ${likes.includes(post.id) ? "liked" : ""} ${likingNotes[post.id] ? "loading" : ""}`}
onClick={() => handleLike(post)}
disabled={likingNotes[post.id]}
>
<svg viewBox="0 0 24 24" className="heart">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
<span>{post.data.likes || ""}</span>
</button>
</div>
</div>
</article>
))}
</div>
{/* Footer */}
<footer className="app-footer">
<div className="footer-content">
Built by{" "}
<a
href="https://weavedb.dev"
target="_blank"
rel="noopener noreferrer"
className="footer-brand"
>
WeaveDB
</a>
</div>
</footer>
</div>
{/* Toast */}
{showToast && (
<div className="toast-overlay">
<div className="toast-card">
<div className="toast-icon">✓</div>
<div className="toast-content">
<div className="toast-title">Post successful!</div>
<a
href={`${process.env.NEXT_PUBLIC_SCAN_URL}/db/${process.env.NEXT_PUBLIC_DB_ID}/tx/${slot}?url=${process.env.NEXT_PUBLIC_RU_URL}`}
target="_blank"
rel="noopener noreferrer"
className="toast-link"
>
View transaction
</a>
</div>
<button className="toast-close" onClick={() => setShowToast(false)}>
×
</button>
</div>
</div>
)}
</>
)
}
Add .env.local
with your DB_ID
.
NEXT_PUBLIC_DB_ID="c5ulwr94nkxiqkpek9skxzjmmvmfgwluod_btvvqwas"
NEXT_PUBLIC_RU_URL="http://localhost:6364"
NEXT_PUBLIC_SCAN_URL="http://localhost:3000"
Run the app.
yarn dev --port 4000
Now the app is runnint at localhost:4000.
Demo
A working version is running at social-theta-nine.vercel.app.