Developer Quickstart
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!
Step 1: Set Up Your Wallet (5 minutes)
First, you'll need a Stacks wallet to interact with the blockchain.
Install Leather Wallet
Visit leather.io and install the browser extension
Create a new wallet or import an existing one
Important: Switch to the Testnet network in your wallet settings
Get testnet STX tokens from the Stacks Testnet Faucet
Your wallet is now ready for testnet development!
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
Now that you've seen the contract in action, let's talk about the core Clarity concepts you just used. When you write define-map
, you're creating a data structure for storing key-value pairs on the blockchain. Think of it like creating a table in a database. The define-data-var
function creates a variable that persists on the blockchain, perfect for keeping track of counters or settings that need to survive between function calls.
When you declare a function with define-public
, you're creating a function that can modify blockchain state and be called by anyone externally. This is different from define-read-only
, which creates functions that can only read existing data without changing anything. This separation helps prevent accidental state changes and makes your contract's behavior more predictable.
The tx-sender
variable is particularly useful because it's automatically set by the blockchain to contain the address of whoever called your function. You can't fake this value, which makes it perfect for authentication. When you need to create temporary variables within a function, you'll use let
to set up local variables that only exist during that function call.
Finally, every public function in Clarity must return a response type, which is why you see ok
wrapping our return values. This ensures that every function call has a clear success or failure outcome, making your contracts much more predictable and easier to debug.
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
Visit the Stacks Explorer Sandbox
Connect your Leather wallet (make sure you're on testnet)
Paste your contract code into the editor
Give your contract a name (e.g., "message-board") or just use the default generated name
Click "Deploy Contract"
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
In the explorer, find your deployed contract
Scroll down a bit and click on "Available Functions" to view its functions
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)Call
get-message
with IDu1
to read it backCall
get-message-count
to see the total
Your contract is now live and functional on the blockchain!
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:
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
Go back to the Stacks Explorer and find your deployed contract
Copy the contract address (it looks like
ST1ABC...123.message-board
)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
Clarity Book: Comprehensive guide to Clarity development
Clarity Reference: Complete documentation of Clarity functions
Clarity Crash Course: Quick introduction to Clarity concepts
Explore Advanced Features
Error Handling: Learn about Clarity's
try!
andunwrap!
functionsAccess Control: Implement admin functions and permissions
Token Standards: Build fungible (SIP-010) and non-fungible (SIP-009) tokens
Development Tools
Clarinet: Local development environment for Clarity
Hiro Platform: Hosted development environment
Stacks Explorer: View transactions and contracts on mainnet
Community Resources
Stacks Discord: Connect with other developers
Stacks Forum: Ask questions and share projects
Stacks GitHub: Contribute to the ecosystem
Happy building! π
Last updated
Was this helpful?