Skip to content

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.

/test/main.test.js
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.

/db/schema.js
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.

/db/auth.js
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.

/test/main.test.js
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.

/db/schema.js
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.

/db/auth.js
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.

db/indexes.js
export default {
  likes: [[["actor"], ["object"]]],
}

Now users can like notes.

await a1.set("add:like", { object: noteID }, "likes")

Update the tests.

/test/main.test.js
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.

/db/schema.js
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.

/db/triggers.js
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.

/test/main.test.js
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
/pages/index.js
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.

/.env.local
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.