Tutorial: minisocial dApp

June 24, 2026 ยท View on GitHub

We will create a MiniSocial realm, a minimalist social media application. This tutorial showcases the full local development flow for Gno.

Find the full app at staging.gno.land/r/docs/minisocial/v1.

Prerequisites

Complete Getting started first. See gnodev for deeper local-node detail.

Setup

Create a folder, initialize a module, and create three files:

mkdir minisocial && cd minisocial
gno mod init gno.land/r/example/minisocial
touch types.gno posts.gno render.gno

Swap example for your own namespace if you plan to deploy this realm. See Namespaces.

While all code can be stored in a single file, separating types, business logic, and rendering makes a realm easier to read as it grows.

Core functionality

types.gno

We use types.gno for our types. Let's declare a Post struct that will hold all the data of a single post, importing the time package to handle time-related functionality.

package minisocial

import (
	"time" // For handling time operations
)

// Post defines the main data we keep about each post
type Post struct {
	text      string    // Main text body
	author    address   // Address of the post author, provided by the execution context
	createdAt time.Time // When the post was created
}

The address keyword is a built-in type that represents a Gno address.

Standard libraries such as time are ported over directly from Go. Check out the Go-Gno Compatibility page for more info.

posts.gno

posts.gno holds two things: a posts slice that stores every post, and a CreatePost function that anyone can call via a transaction to add to it. Top-level variables like posts are persisted to storage after each transaction. Create the file like this:

package minisocial

import (
	"chain/runtime/unsafe" // Stack-walking primitives (see unsafe.PreviousRealm docs)
	"errors"               // For handling errors
	"time"                 // For handling time
)

var posts []*Post

// CreatePost creates a new post
// As the function modifies state (i.e. the `posts` slice),
// it needs to be crossing. This is defined by the first argument being of type `realm`
func CreatePost(_ realm, text string) error {
	// If the body of the post is empty, return an error
	if text == "" {
		return errors.New("empty post text")
	}

	// Append the new post to the list
	posts = append(posts, &Post{
		text:      text,                              // Set the input text
		author:    unsafe.PreviousRealm().Address(), // The author of the address is the previous realm, the realm that called this one
		createdAt: time.Now(),                        // Capture the time of the transaction, in this case the block timestamp
	})

	return nil
}

A few things to note:

  • In Gno, returning errors does not revert any state changes. Follow Go's best practices: return early in your code and modify state only after you are sure all security checks in your code have passed. To discard (revert) state changes, use panic().
  • To get the caller of CreatePost, we need to import chain/runtime/unsafe, which provides access to the function caller, and use unsafe.PreviousRealm().Address(). Check out the realm concept page and the chain/runtime/unsafe package reference page for more info.
  • In Gno, time.Now() returns the timestamp of the block the transaction was included in, instead of the system time.

Rendering

Let's build the "front end" of our app.

A core feature of Gno is that developers can serve a Markdown view of their realm state directly from on-chain code, removing the need for a separate frontend framework. To learn more, see Exploring Gno.land.

Start gnodev in the minisocial/ folder and open 127.0.0.1:8888. Since a Render() function is not defined yet, gnoweb returns an error. Let's fix that in render.gno:

package minisocial

func Render(_ string) string {
    return "# MiniSocial"
}

gnodev hot-reloads and you should see MiniSocial as a Header 1 in gnoweb ๐ŸŽ‰

Let's gradually add more functionality:

package minisocial

func Render(_ string) string {
	output := "# MiniSocial\n\n" // \n is needed just like in standard Markdown

	// Handle the edge case
	if len(posts) == 0 {
		output += "No posts.\n"
		return output
	}

	// Let's append the text of each post to the output
	for _, post := range posts {
		output += post.text + "\n\n"
	}

	return output
}

We can now use gnokey to call the CreatePost function and see how our posts look rendered on gnoweb. From the gnoweb realm page, the Docs tab gives you a ready-to-copy gnokey command:

gnokey maketx call \
-pkgpath "gno.land/r/example/minisocial" \
-func "CreatePost" \
-args "This is my first post" \
-gas-fee 1000000ugnot -gas-wanted 5000000 \
-chainid "dev" \
-remote "tcp://127.0.0.1:26657" \
MyKey

If the transaction went through, we should see This is my first post under the header.

We can make this prettier by adding a custom String() method on the Post struct. Update types.gno to:

package minisocial

import (
	"time" // For handling time operations

	"gno.land/p/nt/ufmt/v0"
)

// Post defines the main data we keep about each post
type Post struct {
	text      string
	author    address
	createdAt time.Time
}

// String stringifies a Post
func (p Post) String() string {
	out := p.text + "\n\n"

	// We can use `ufmt` to format strings, and the built-in time library formatting function
	out += ufmt.Sprintf("_by %s on %s_\n\n", p.author, p.createdAt.Format("02 Jan 2006, 15:04"))

	return out
}

Here, package ufmt is used to provide string formatting functionality. It can be imported as gno.land/p/nt/ufmt/v0.

With this, we can expand our Render() function in render.gno as follows:

package minisocial

import "gno.land/p/nt/ufmt/v0" // Gno counterpart to `fmt`, for formatting strings

func Render(_ string) string {
	output := "# MiniSocial\n\n" // \n is needed just like in standard Markdown

	// Handle the edge case
	if len(posts) == 0 {
		output += "No posts.\n"
		return output
	}

	// Let's append the text of each post to the output
	for i, post := range posts {
		// Let's append some post metadata
		output += ufmt.Sprintf("#### Post #%d\n\n", i)
		// Add the stringified post
		output += post.String()
		// Add a line break for cleaner UI
		output += "---\n\n"
	}

	return output
}

Now, try publishing a few more posts to see that the rendering works properly.

Testing our code

Testing is an essential part of developing reliable applications. Here we will cover a simple test case and then showcase a more advanced approach using Table-Driven Tests (TDT), a pattern commonly used in Go.

Let's create a posts_test.gno file, and add the following code:

package minisocial

import (
	"strings"
	"testing"

	"gno.land/p/nt/testutils/v0" // Provides testing utilities
)

func TestCreatePostSingle(cur realm, t *testing.T) {
	// Get a test address for alice
	aliceAddr := testutils.TestAddress("alice")
	// TestSetRealm sets the realm caller, in this case Alice
	testing.SetRealm(testing.NewUserRealm(aliceAddr))

	text1 := "Hello World!"

	// To call a crossing function, we pass cross(cur) as the first
	// argument; cross(rlm) validates rlm is the current realm and
	// flows through to the callee's `cur realm` parameter.
	err := CreatePost(cross(cur), text1)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}

	// Get the rendered page
	got := Render("")

	// Content should have the text and alice's address in it
	if !(strings.Contains(got, text1) && strings.Contains(got, aliceAddr.String())) {
		t.Fatal("expected render to contain text & alice's address")
	}
}

We can add the following test showcasing how TDT works in Gno:

func TestCreatePostMultiple(cur realm, t *testing.T) {
	// Initialize a slice to hold the test posts and their authors
	posts := []struct {
		text   string
		author string
	}{
		{"Hello World!", "alice"},
		{"This is some new text!", "bob"},
		{"Another post by alice", "alice"},
		{"A post by charlie!", "charlie"},
	}

	for _, p := range posts {
		// Set the appropriate caller realm based on the author
		authorAddr := testutils.TestAddress(p.author)
		testing.SetRealm(testing.NewUserRealm(authorAddr))

		// Create the post via cross(cur) so the realm capability flows
		// to CreatePost's `cur realm` parameter.
		err := CreatePost(cross(cur), p.text)
		if err != nil {
			t.Fatalf("expected no error for post '%s', got %v", p.text, err)
		}
	}

	// Get the rendered page
	got := Render("")

	// Check that all posts and their authors are present in the rendered output
	for _, p := range posts {
		expectedText := p.text
		expectedAuthor := testutils.TestAddress(p.author).String() // Get the address for the author
		if !(strings.Contains(got, expectedText) && strings.Contains(got, expectedAuthor)) {
			t.Fatalf("expected render to contain text '%s' and address '%s'", expectedText, expectedAuthor)
		}
	}
}

Running gno test . -v in the minisocial/ folder should show the tests passing:

โฏ gno test . -v
=== RUN   TestCreatePostSingle
--- PASS: TestCreatePostSingle (0.00s)
=== RUN   TestCreatePostMultiple
--- PASS: TestCreatePostMultiple (0.00s)
ok      .       0.87s

Conclusion

You've built a multi-file realm with persistent state, markdown rendering, and unit tests. Congratulations!

Full code of this app can be found on the Staging network, at staging.gno.land/r/docs/minisocial.

Bonus - resolving usernames

Let's make our MiniSocial app even better by resolving addresses to potential usernames registered in the Gno.land User Registry.

The gno.land/r/sys/users realm provides user data. Update types.gno to use it when resolving addresses:

package minisocial

import (
	"time" // For handling time operations

	"gno.land/p/nt/ufmt/v0"
	"gno.land/r/sys/users"
)

// Post defines the main data we keep about each post
type Post struct {
	text      string
	author    address
	createdAt time.Time
}

// String stringifies a Post
func (p Post) String() string {
	out := p.text + "\n\n"

	author := p.author.String()
	// We can import and use the r/sys/users package to resolve addresses
	user := users.ResolveAddress(p.author)
	if user != nil {
		// RenderLink provides a link that is clickable
		// The link goes to the user's profile page
		author = user.RenderLink("")
	}

	out += ufmt.Sprintf("_by %s on %s_\n\n", author, p.createdAt.Format("02 Jan 2006, 15:04"))
	return out
}