Page cover

Developer Quickstart

source: Hiro blog

Build Your First Stacks App in 30 Minutes

Looking to see what building on Stacks is all about? You're in the right place.

This tutorial will help you build a working Stacks application in just 30 minutes. You'll learn the essential tools and concepts needed to build decentralized applications on Stacks, the leading Bitcoin L2.

What you'll build: A simple message board where users can post messages to the blockchain and read messages from others.

What you'll learn:

  • How to write a Clarity smart contract

  • How to deploy contracts to Stacks testnet

  • How to connect a wallet to your app

  • How to interact with contracts from a frontend

Prerequisites:

  • Basic familiarity with web development (HTML, CSS, JavaScript)

  • A modern web browser

  • 30 minutes of your time

Let's get started!

1

Step 1: Set Up Your Wallet (5 minutes)

First, you'll need a Stacks wallet to interact with the blockchain.

Install Leather Wallet

  1. Visit leather.io and install the browser extension

  2. Create a new wallet or import an existing one

  3. Important: Switch to the Testnet network in your wallet settings

  4. Get testnet STX tokens from the Stacks Testnet Faucet

Testnet STX tokens are free and used for testing. They have no real value but let you experiment with Stacks development without cost.

Your wallet is now ready for testnet development!

You don't have to use Leather, two other wallets popular with Stacks users are Xverse and Asigna if you need a multisig.

2

Step 2: Write Your First Clarity Contract (10 minutes)

Clarity is Stacks' smart contract language, designed for safety and predictability. Let's write a simple message board contract.

Clarity is inspired by LISP and uses a functional programming approach. Everything in Clarity is an expression wrapped in parentheses. This can be a bit overwhelming at first if you are used to languages like JavaScript or Solidity, but the learning curve is short and Clarity is a simple language to understand once you dive in and start using it.

For a more detailed introduction, check out the Clarity Crash Course in the docs.

Write the Contract

Open Clarity Playground in your browser. This is an online IDE where you can write and test Clarity code without installing anything.

Delete the existing code and replace it with this message board contract:

;; Simple Message Board Contract
;; This contract allows users to post and read messages

;; Define a map to store messages
;; Key: message ID (uint), Value: message content (string-utf8 280)
(define-map messages uint (string-utf8 280))

;; Define a map to store message authors
(define-map message-authors uint principal)

;; Counter for message IDs
(define-data-var message-count uint u0)

;; Public function to add a new message
(define-public (add-message (content (string-utf8 280)))
  (let ((id (+ (var-get message-count) u1)))
    (map-set messages id content)
    (map-set message-authors id tx-sender)
    (var-set message-count id)
    (ok id)))

;; Read-only function to get a message by ID
(define-read-only (get-message (id uint))
  (map-get? messages id))

;; Read-only function to get message author
(define-read-only (get-message-author (id uint))
  (map-get? message-authors id))

;; Read-only function to get total message count
(define-read-only (get-message-count)
  (var-get message-count))

;; Read-only function to get the last few messages
(define-read-only (get-recent-messages (count uint))
  (let ((total-count (var-get message-count)))
    (if (> count total-count)
      (map get-message (list u1 u2 u3 u4 u5))
      (map get-message (list
        (- total-count (- count u1))
        (- total-count (- count u2))
        (- total-count (- count u3))
        (- total-count (- count u4))
        (- total-count (- count u5)))))))

Test the Contract

Click "Deploy", and go to the command line in the bottom right corner and try calling the functions.

We are using the contract-call? method to call the functions in the contract that we just deployed within the playground.

;; Test adding a message
(contract-call? .contract-1 add-message u"Hello, Stacks!")

;; Test reading the message
(contract-call? .contract-1 get-message u1)

;; Test getting the count
(contract-call? .contract-1 get-message-count)

You should see the contract working in the evaluation panel on the right!

Key Clarity Concepts Explained

  • define-map: creates a map / key-value store on-chain (like a simple table).

  • define-data-var: creates a single persistent variable (used for counters, settings).

  • define-public: public function that can modify blockchain state.

  • define-read-only: functions that can only read state and don't modify it.

  • tx-sender: automatically set to the address of whoever called the function (useful for authentication).

  • let: create local variables inside functions.

  • All public functions return a response type: (ok value) or (err error).

🔍 Deep Dive: Understanding the Contract Code (Optional)

Want to understand exactly what each part of the contract is doing? Let's walk through every function and concept used in our message board contract. Links to the official documentation are included for each function, so you may dive deeper if you want.

How We Store Data on the Blockchain

We use define-map to create what's essentially a database table on the blockchain:

(define-map messages uint (string-utf8 280))

We also create another map to track who wrote each message:

(define-map message-authors uint principal)

We keep a message counter with:

(define-data-var message-count uint u0)

The Heart of Our Contract: Adding Messages

The primary state-changing function:

(define-public (add-message (content (string-utf8 280)))
  (let ((id (+ (var-get message-count) u1)))
    (map-set messages id content)
    (map-set message-authors id tx-sender)
    (var-set message-count id)
    (ok id)))

Key points:

  • var-get reads the message counter.

  • + uses prefix notation (LISP-style).

  • map-set stores the content and author.

  • tx-sender is the caller's address.

  • The function returns (ok id) on success.

Reading Messages Back

Example read-only function:

(define-read-only (get-message (id uint))
  (map-get? messages id))

map-get? returns (some value) or none, forcing explicit handling of missing data.

Other read-only functions:

(define-read-only (get-message-author (id uint))
  (map-get? message-authors id))

(define-read-only (get-message-count)
  (var-get message-count))

A More Complex Function: Getting Recent Messages

(define-read-only (get-recent-messages (count uint))
  (let ((total-count (var-get message-count)))
    (if (> count total-count)
      (map get-message (list u1 u2 u3 u4 u5))
      (map get-message (list
        (- total-count (- count u1))
        (- total-count (- count u2))
        (- total-count (- count u3))
        (- total-count (- count u4))
        (- total-count (- count u5)))))))

This shows conditional logic (if), prefix operators, map to apply a function over a list, and list arithmetic to determine recent message IDs.

What Makes Clarity Special

  • Response types: functions return (ok value) or (err error) and state changes are reverted on err.

  • Optional types: map-get? returns some or none instead of null.

  • Static analysis at deployment time prevents many runtime errors.

  • No recursion or unbounded loops (decidability), making execution costs predictable.

  • tx-sender vs contract-caller — be cautious about which you use for authorization.

Clarity's type safety and static analysis help catch issues at deploy time. This contract demonstrates common patterns: maps, data vars, public functions, read-only queries, tx-sender usage, and predictable response types.

3

Step 3: Deploy Your Contract (5 minutes)

Now let's deploy your contract to the Stacks testnet so you can interact with it from a web application.

Deploy via Stacks Explorer

  1. Connect your Leather wallet (make sure you're on testnet)

  2. Paste your contract code into the editor

  3. Give your contract a name (e.g., "message-board") or just use the default generated name

  4. Click "Deploy Contract"

  5. Confirm the transaction in your wallet

The deployment should only take a few seconds. Once complete, you'll see your contract address in the explorer. Here's an example transaction deploying this contract.

Test Your Deployed Contract

  1. In the explorer, find your deployed contract

  2. Scroll down a bit and click on "Available Functions" to view its functions

  3. Try calling add-message with a test message (you'll need to change the post conditions toggle to allow mode, there is a dedicated docs page talking about Post Conditions on Stacks)

  4. Call get-message with ID u1 to read it back

  5. Call get-message-count to see the total

Your contract is now live and functional on the blockchain!

4

Step 4: Build the Frontend (10 minutes)

Let's create a simple web interface to interact with your contract.

Set Up the Project

Create a new React project:

npm create vite@latest my-message-board -- --template react
cd my-message-board
npm install

Install the Stacks.js libraries:

npm install @stacks/connect @stacks/transactions @stacks/network

Create the App Component

Replace the contents of src/App.jsx with the following:

Since this is a quickstart, we won't dive into a long explanation of exactly what this code is doing. We suggest going and checking out Hiro's Docs in order to get a handle on how stacks.js works.

import { useState, useEffect } from "react";
import { connect, disconnect, isConnected, request } from "@stacks/connect";
import {
  fetchCallReadOnlyFunction,
  stringUtf8CV,
  uintCV,
} from "@stacks/transactions";
import "./App.css";

const network = "testnet";

// Replace with your contract address
const CONTRACT_ADDRESS = "YOUR_CONTRACT_ADDRESS_HERE";
const CONTRACT_NAME = "message-board";

function App() {
  const [connected, setConnected] = useState(false);
  const [messages, setMessages] = useState([]);
  const [newMessage, setNewMessage] = useState("");
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setConnected(isConnected());
    if (isConnected()) {
      loadMessages();
    }
  }, []);

  // Check for connection changes
  useEffect(() => {
    const checkConnection = () => {
      const connectionStatus = isConnected();
      if (connectionStatus !== connected) {
        setConnected(connectionStatus);
        if (connectionStatus) {
          loadMessages();
        }
      }
    };

    const intervalId = setInterval(checkConnection, 500);
    return () => clearInterval(intervalId);
  }, [connected]);

  const connectWallet = async () => {
    try {
      await connect({
        appDetails: {
          name: "Message Board",
          icon: window.location.origin + "/logo.svg",
        },
        onFinish: () => {
          setConnected(true);
          // Small delay to ensure connection is fully established
          setTimeout(() => {
            loadMessages();
          }, 100);
        },
      });
    } catch (error) {
      console.error("Connection failed:", error);
    }
  };

  const disconnectWallet = () => {
    disconnect();
    setConnected(false);
    setMessages([]);
  };

  const loadMessages = async () => {
    try {
      // Get message count
      const countResult = await fetchCallReadOnlyFunction({
        contractAddress: CONTRACT_ADDRESS,
        contractName: CONTRACT_NAME,
        functionName: "get-message-count",
        functionArgs: [],
        network,
        senderAddress: CONTRACT_ADDRESS,
      });

      const count = parseInt(countResult.value);

      // Load recent messages
      const messagePromises = [];
      for (let i = Math.max(1, count - 4); i <= count; i++) {
        messagePromises.push(
          fetchCallReadOnlyFunction({
            contractAddress: CONTRACT_ADDRESS,
            contractName: CONTRACT_NAME,
            functionName: "get-message",
            functionArgs: [uintCV(i)],
            network,
            senderAddress: CONTRACT_ADDRESS,
          })
        );
      }

      const messageResults = await Promise.all(messagePromises);
      const loadedMessages = messageResults
        .map((result, index) => ({
          id: count - messageResults.length + index + 1,
          content: result.value.value,
        }))
        .filter((msg) => msg.content !== undefined);

      setMessages(loadedMessages);
    } catch (error) {
      console.error("Error loading messages:", error);
    }
  };

  const postMessage = async () => {
    if (!newMessage.trim()) return;

    setLoading(true);
    try {
      const result = await request("stx_callContract", {
        contract: `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`,
        functionName: "add-message",
        functionArgs: [stringUtf8CV(newMessage)],
        network,
      });

      console.log("Transaction submitted:", result.txid);
      setNewMessage("");

      // Reload messages after a delay to allow the transaction to process
      setTimeout(() => {
        loadMessages();
        setLoading(false);
      }, 2000);
    } catch (error) {
      console.error("Error posting message:", error);
      setLoading(false);
    }
  };

  return (
    <div className="App">
      <header className="App-header">
        <h1>📝 Stacks Message Board</h1>

        {!connected ? (
          <button onClick={connectWallet} className="connect-button">
            Connect Wallet
          </button>
        ) : (
          <button onClick={disconnectWallet} className="disconnect-button">
            Disconnect
          </button>
        )}
      </header>

      {connected && (
        <main className="App-main">
          <div className="post-message">
            <h2>Post a Message</h2>
            <div className="message-input">
              <input
                type="text"
                value={newMessage}
                onChange={(e) => setNewMessage(e.target.value)}
                placeholder="What's on your mind?"
                maxLength={280}
                disabled={loading}
              />
              <button
                onClick={postMessage}
                disabled={loading || !newMessage.trim()}
              >
                {loading ? "Posting..." : "Post"}
              </button>
            </div>
          </div>

          <div className="messages">
            <h2>Recent Messages</h2>
            <button onClick={loadMessages} className="refresh-button">
              Refresh
            </button>
            {messages.length === 0 ? (
              <p>No messages yet. Be the first to post!</p>
            ) : (
              <ul>
                {messages.map((message) => (
                  <li key={message.id}>
                    <strong>Message #{message.id}:</strong> {message.content}
                  </li>
                ))}
              </ul>
            )}
          </div>
        </main>
      )}
    </div>
  );
}

export default App;

Add Basic Styling

Update src/App.css:

.App {
  max-width: 800px;
  width: 100%;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
    "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
    "Helvetica Neue", sans-serif;
}

.App-header {
  text-align: center;
  margin-bottom: 40px;
  padding: 20px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-radius: 12px;
  color: white;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.App-header h1 {
  color: white;
  margin-bottom: 20px;
  font-size: 2.5rem;
  font-weight: 700;
  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.connect-button,
.disconnect-button {
  background-color: rgba(255, 255, 255, 0.2);
  color: white;
  border: 2px solid rgba(255, 255, 255, 0.3);
  padding: 12px 28px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 16px;
  font-weight: 600;
  transition: all 0.3s ease;
  backdrop-filter: blur(10px);
}

.connect-button:hover,
.disconnect-button:hover {
  background-color: rgba(255, 255, 255, 0.3);
  border-color: rgba(255, 255, 255, 0.5);
  transform: translateY(-2px);
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}

.post-message {
  margin-bottom: 40px;
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.message-input {
  display: flex;
  gap: 10px;
  margin-top: 10px;
}

.message-input input {
  flex: 1;
  padding: 10px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  font-size: 16px;
}

.message-input button {
  background-color: #10b981;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
}

.message-input button:disabled {
  background-color: #9ca3af;
  cursor: not-allowed;
}

.messages {
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.refresh-button {
  background-color: #6b7280;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  margin-bottom: 20px;
}

.messages ul {
  list-style: none;
  padding: 0;
}

.messages li {
  padding: 10px;
  border-bottom: 1px solid #e5e7eb;
  margin-bottom: 10px;
}

.messages li:last-child {
  border-bottom: none;
}

Update the Contract Address

  1. Go back to the Stacks Explorer and find your deployed contract

  2. Copy the contract address (it looks like ST1ABC...123.message-board)

  3. Replace YOUR_CONTRACT_ADDRESS_HERE in the App.jsx file with your actual contract address and the contract name with the actual name

Run Your App

npm run dev

Visit http://localhost:5173 and you should see your message board app! Connect your wallet and try posting a message.

Congratulations! 🎉

You've just built your first Stacks application! Here's what you accomplished:

  • ✅ Wrote a Clarity smart contract with data storage and public functions

  • ✅ Deployed the contract to Stacks testnet

  • ✅ Built a React frontend that connects to a Stacks wallet

  • ✅ Integrated your frontend with your smart contract

  • ✅ Posted and read data from the blockchain

Next Steps

Now that you have the basics down, here are some ways to continue your Stacks development journey:

Learn More About Clarity

Explore Advanced Features

  • Error Handling: Learn about Clarity's try! and unwrap! functions

  • Access Control: Implement admin functions and permissions

  • Token Standards: Build fungible (SIP-010) and non-fungible (SIP-009) tokens

Development Tools

Community Resources

Last updated

Was this helpful?