Fullstack dApp
Build a complete decentralized application from scratch—a Todo List dApp with an Anchor program backend and React frontend. This capstone integrates everything you've learned.
Project Overview
Text
┌─────────────────────────────────────────────────────────────────┐
│ Fullstack Todo dApp │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 📝 My On-Chain Todo List [Connect] 🔗 │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ [ ] Learn Anchor framework [Edit][🗑️] │ │ │
│ │ │ [✓] Deploy first Solana program [Edit][🗑️] │ │ │
│ │ │ [ ] Build fullstack dApp [Edit][🗑️] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Add new todo: [________________________] [Add] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Stats: 3 todos • 1 completed • 2 pending │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Architecture: │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ React │ ───▶ │ Anchor │ ───▶ │ Solana Program │ │
│ │ Frontend │ │ Client │ │ (On-Chain) │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Structure
Text
todo-dapp/
├── programs/
│ └── todo/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # Anchor program
├── app/ # Next.js frontend
│ ├── components/
│ │ ├── TodoList.tsx
│ │ ├── TodoItem.tsx
│ │ ├── AddTodo.tsx
│ │ └── WalletProvider.tsx
│ ├── hooks/
│ │ └── useTodo.ts
│ ├── idl/
│ │ └── todo.json
│ └── app/
│ ├── layout.tsx
│ └── page.tsx
├── tests/
│ └── todo.ts
├── Anchor.toml
└── package.json
Part 1: Anchor Program
Program Setup
Bash
# Initialize Anchor project
anchor init todo-dapp
cd todo-dapp
# Build to get program ID
anchor build
solana address -k target/deploy/todo-keypair.json
# Update lib.rs with program ID
# Then rebuild
anchor build
Program Code
Rust
// programs/todo/src/lib.rs
use anchor_lang::prelude::*;
declare_id!("YOUR_PROGRAM_ID_HERE");
#[program]
pub mod todo {
use super::*;
/// Initialize a new todo list for the user
pub fn initialize_list(ctx: Context<InitializeList>) -> Result<()> {
let todo_list = &mut ctx.accounts.todo_list;
todo_list.authority = ctx.accounts.authority.key();
todo_list.count = 0;
todo_list.bump = ctx.bumps.todo_list;
msg!("Todo list initialized for: {}", ctx.accounts.authority.key());
Ok(())
}
/// Add a new todo item
pub fn add_todo(ctx: Context<AddTodo>, content: String) -> Result<()> {
require!(content.len() <= 280, TodoError::ContentTooLong);
let todo_list = &mut ctx.accounts.todo_list;
let todo_item = &mut ctx.accounts.todo_item;
todo_item.id = todo_list.count;
todo_item.authority = ctx.accounts.authority.key();
todo_item.content = content.clone();
todo_item.completed = false;
todo_item.created_at = Clock::get()?.unix_timestamp;
todo_item.bump = ctx.bumps.todo_item;
todo_list.count = todo_list.count.checked_add(1)
.ok_or(TodoError::CounterOverflow)?;
msg!("Todo #{} added: {}", todo_item.id, content);
Ok(())
}
/// Toggle todo completion status
pub fn toggle_todo(ctx: Context<UpdateTodo>) -> Result<()> {
let todo_item = &mut ctx.accounts.todo_item;
todo_item.completed = !todo_item.completed;
msg!(
"Todo #{} toggled to: {}",
todo_item.id,
if todo_item.completed { "completed" } else { "pending" }
);
Ok(())
}
/// Update todo content
pub fn update_todo(ctx: Context<UpdateTodo>, content: String) -> Result<()> {
require!(content.len() <= 280, TodoError::ContentTooLong);
let todo_item = &mut ctx.accounts.todo_item;
todo_item.content = content.clone();
msg!("Todo #{} updated: {}", todo_item.id, content);
Ok(())
}
/// Delete a todo item
pub fn delete_todo(ctx: Context<DeleteTodo>) -> Result<()> {
msg!("Todo #{} deleted", ctx.accounts.todo_item.id);
Ok(())
}
}
// ============ Accounts ============
#[derive(Accounts)]
pub struct InitializeList<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(
init,
payer = authority,
space = TodoList::SIZE,
seeds = [b"todo-list", authority.key().as_ref()],
bump
)]
pub todo_list: Account<'info, TodoList>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
#[instruction(content: String)]
pub struct AddTodo<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(
mut,
seeds = [b"todo-list", authority.key().as_ref()],
bump = todo_list.bump,
constraint = todo_list.authority == authority.key() @ TodoError::Unauthorized
)]
pub todo_list: Account<'info, TodoList>,
#[account(
init,
payer = authority,
space = TodoItem::SIZE,
seeds = [
b"todo-item",
authority.key().as_ref(),
&todo_list.count.to_le_bytes()
],
bump
)]
pub todo_item: Account<'info, TodoItem>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct UpdateTodo<'info> {
#[account(
constraint = todo_item.authority == authority.key() @ TodoError::Unauthorized
)]
pub authority: Signer<'info>,
#[account(
mut,
seeds = [
b"todo-item",
authority.key().as_ref(),
&todo_item.id.to_le_bytes()
],
bump = todo_item.bump
)]
pub todo_item: Account<'info, TodoItem>,
}
#[derive(Accounts)]
pub struct DeleteTodo<'info> {
#[account(
mut,
constraint = todo_item.authority == authority.key() @ TodoError::Unauthorized
)]
pub authority: Signer<'info>,
#[account(
mut,
close = authority,
seeds = [
b"todo-item",
authority.key().as_ref(),
&todo_item.id.to_le_bytes()
],
bump = todo_item.bump
)]
pub todo_item: Account<'info, TodoItem>,
}
// ============ State ============
#[account]
pub struct TodoList {
pub authority: Pubkey,
pub count: u64,
pub bump: u8,
}
impl TodoList {
pub const SIZE: usize = 8 + // discriminator
32 + // authority
8 + // count
1; // bump
}
#[account]
pub struct TodoItem {
pub id: u64,
pub authority: Pubkey,
pub content: String,
pub completed: bool,
pub created_at: i64,
pub bump: u8,
}
impl TodoItem {
pub const SIZE: usize = 8 + // discriminator
8 + // id
32 + // authority
4 + 280 + // content (string prefix + max chars)
1 + // completed
8 + // created_at
1; // bump
}
// ============ Errors ============
#[error_code]
pub enum TodoError {
#[msg("Content exceeds 280 characters")]
ContentTooLong,
#[msg("Counter overflow")]
CounterOverflow,
#[msg("Unauthorized")]
Unauthorized,
}
Part 2: Program Tests
TypeScript
// tests/todo.ts
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Todo } from "../target/types/todo";
import { expect } from "chai";
describe("todo", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Todo as Program<Todo>;
const authority = provider.wallet;
// Derive PDAs
const [todoListPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("todo-list"), authority.publicKey.toBuffer()],
program.programId,
);
const getTodoItemPda = (index: number) => {
return anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("todo-item"),
authority.publicKey.toBuffer(),
new anchor.BN(index).toArrayLike(Buffer, "le", 8),
],
program.programId,
)[0];
};
it("Initializes todo list", async () => {
await program.methods
.initializeList()
.accounts({
authority: authority.publicKey,
todoList: todoListPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const todoList = await program.account.todoList.fetch(todoListPda);
expect(todoList.authority.toBase58()).to.equal(
authority.publicKey.toBase58(),
);
expect(todoList.count.toNumber()).to.equal(0);
});
it("Adds a todo item", async () => {
const content = "Learn Anchor framework";
const todoItemPda = getTodoItemPda(0);
await program.methods
.addTodo(content)
.accounts({
authority: authority.publicKey,
todoList: todoListPda,
todoItem: todoItemPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const todoItem = await program.account.todoItem.fetch(todoItemPda);
expect(todoItem.content).to.equal(content);
expect(todoItem.completed).to.equal(false);
expect(todoItem.id.toNumber()).to.equal(0);
const todoList = await program.account.todoList.fetch(todoListPda);
expect(todoList.count.toNumber()).to.equal(1);
});
it("Adds multiple todos", async () => {
const contents = ["Build dApp", "Deploy to mainnet"];
for (let i = 0; i < contents.length; i++) {
const todoList = await program.account.todoList.fetch(todoListPda);
const todoItemPda = getTodoItemPda(todoList.count.toNumber());
await program.methods
.addTodo(contents[i])
.accounts({
authority: authority.publicKey,
todoList: todoListPda,
todoItem: todoItemPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
}
const todoList = await program.account.todoList.fetch(todoListPda);
expect(todoList.count.toNumber()).to.equal(3);
});
it("Toggles todo completion", async () => {
const todoItemPda = getTodoItemPda(0);
await program.methods
.toggleTodo()
.accounts({
authority: authority.publicKey,
todoItem: todoItemPda,
})
.rpc();
let todoItem = await program.account.todoItem.fetch(todoItemPda);
expect(todoItem.completed).to.equal(true);
// Toggle back
await program.methods
.toggleTodo()
.accounts({
authority: authority.publicKey,
todoItem: todoItemPda,
})
.rpc();
todoItem = await program.account.todoItem.fetch(todoItemPda);
expect(todoItem.completed).to.equal(false);
});
it("Updates todo content", async () => {
const todoItemPda = getTodoItemPda(0);
const newContent = "Master Anchor framework";
await program.methods
.updateTodo(newContent)
.accounts({
authority: authority.publicKey,
todoItem: todoItemPda,
})
.rpc();
const todoItem = await program.account.todoItem.fetch(todoItemPda);
expect(todoItem.content).to.equal(newContent);
});
it("Deletes a todo", async () => {
const todoItemPda = getTodoItemPda(2);
const balanceBefore = await provider.connection.getBalance(
authority.publicKey,
);
await program.methods
.deleteTodo()
.accounts({
authority: authority.publicKey,
todoItem: todoItemPda,
})
.rpc();
// Account should be closed
const account = await provider.connection.getAccountInfo(todoItemPda);
expect(account).to.be.null;
// Rent should be returned
const balanceAfter = await provider.connection.getBalance(
authority.publicKey,
);
expect(balanceAfter).to.be.greaterThan(balanceBefore - 10000);
});
it("Fails with content too long", async () => {
const todoList = await program.account.todoList.fetch(todoListPda);
const todoItemPda = getTodoItemPda(todoList.count.toNumber());
const longContent = "a".repeat(300);
try {
await program.methods
.addTodo(longContent)
.accounts({
authority: authority.publicKey,
todoList: todoListPda,
todoItem: todoItemPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
expect.fail("Should have thrown");
} catch (err: any) {
expect(err.error.errorCode.code).to.equal("ContentTooLong");
}
});
});
Part 3: Frontend Setup
Bash
# Create Next.js app in the project
cd todo-dapp
bunx create-next-app@latest app --typescript --tailwind --app --no-eslint
# Install dependencies
cd app
bun add @solana/web3.js @coral-xyz/anchor @solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets \
@tanstack/react-query
# Copy IDL
mkdir -p src/idl
cp ../target/idl/todo.json src/idl/
Part 4: Frontend Hook
TypeScript
// hooks/useTodo.ts
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { Program, AnchorProvider, BN, setProvider } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import idl from "@/idl/todo.json";
const PROGRAM_ID = new PublicKey("YOUR_PROGRAM_ID_HERE");
interface TodoItem {
publicKey: PublicKey;
id: number;
content: string;
completed: boolean;
createdAt: number;
}
interface TodoList {
count: number;
}
export function useTodo() {
const { connection } = useConnection();
const wallet = useWallet();
const queryClient = useQueryClient();
const program = useMemo(() => {
if (!wallet.publicKey) return null;
const provider = new AnchorProvider(connection, wallet as any, {
commitment: "confirmed",
});
setProvider(provider);
return new Program(idl as any, PROGRAM_ID, provider);
}, [connection, wallet]);
const todoListPda = useMemo(() => {
if (!wallet.publicKey) return null;
const [pda] = PublicKey.findProgramAddressSync(
[Buffer.from("todo-list"), wallet.publicKey.toBuffer()],
PROGRAM_ID,
);
return pda;
}, [wallet.publicKey]);
const getTodoItemPda = (index: number) => {
if (!wallet.publicKey) return null;
const [pda] = PublicKey.findProgramAddressSync(
[
Buffer.from("todo-item"),
wallet.publicKey.toBuffer(),
new BN(index).toArrayLike(Buffer, "le", 8),
],
PROGRAM_ID,
);
return pda;
};
// Fetch todo list
const { data: todoList, isLoading: isLoadingList } = useQuery({
queryKey: ["todoList", todoListPda?.toBase58()],
queryFn: async (): Promise<TodoList | null> => {
if (!program || !todoListPda) return null;
try {
const list = await program.account.todoList.fetch(todoListPda);
return { count: list.count.toNumber() };
} catch {
return null;
}
},
enabled: !!program && !!todoListPda,
});
// Fetch all todo items
const { data: todos, isLoading: isLoadingTodos } = useQuery({
queryKey: ["todos", wallet.publicKey?.toBase58()],
queryFn: async (): Promise<TodoItem[]> => {
if (!program || !wallet.publicKey) return [];
const accounts = await program.account.todoItem.all([
{
memcmp: {
offset: 8 + 8, // Skip discriminator + id
bytes: wallet.publicKey.toBase58(),
},
},
]);
return accounts
.map((acc) => ({
publicKey: acc.publicKey,
id: acc.account.id.toNumber(),
content: acc.account.content,
completed: acc.account.completed,
createdAt: acc.account.createdAt.toNumber(),
}))
.sort((a, b) => a.id - b.id);
},
enabled: !!program && !!wallet.publicKey,
refetchInterval: 10000,
});
// Initialize list
const initializeMutation = useMutation({
mutationFn: async () => {
if (!program || !wallet.publicKey || !todoListPda) {
throw new Error("Not connected");
}
return program.methods
.initializeList()
.accounts({
authority: wallet.publicKey,
todoList: todoListPda,
})
.rpc();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todoList"] });
},
});
// Add todo
const addTodoMutation = useMutation({
mutationFn: async (content: string) => {
if (!program || !wallet.publicKey || !todoListPda || !todoList) {
throw new Error("Not connected");
}
const todoItemPda = getTodoItemPda(todoList.count);
if (!todoItemPda) throw new Error("Invalid PDA");
return program.methods
.addTodo(content)
.accounts({
authority: wallet.publicKey,
todoList: todoListPda,
todoItem: todoItemPda,
})
.rpc();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todoList"] });
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// Toggle todo
const toggleTodoMutation = useMutation({
mutationFn: async (todo: TodoItem) => {
if (!program || !wallet.publicKey) {
throw new Error("Not connected");
}
return program.methods
.toggleTodo()
.accounts({
authority: wallet.publicKey,
todoItem: todo.publicKey,
})
.rpc();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// Update todo
const updateTodoMutation = useMutation({
mutationFn: async ({
todo,
content,
}: {
todo: TodoItem;
content: string;
}) => {
if (!program || !wallet.publicKey) {
throw new Error("Not connected");
}
return program.methods
.updateTodo(content)
.accounts({
authority: wallet.publicKey,
todoItem: todo.publicKey,
})
.rpc();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// Delete todo
const deleteTodoMutation = useMutation({
mutationFn: async (todo: TodoItem) => {
if (!program || !wallet.publicKey) {
throw new Error("Not connected");
}
return program.methods
.deleteTodo()
.accounts({
authority: wallet.publicKey,
todoItem: todo.publicKey,
})
.rpc();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return {
todoList,
todos: todos || [],
isLoading: isLoadingList || isLoadingTodos,
isInitialized: !!todoList,
initialize: initializeMutation.mutateAsync,
addTodo: addTodoMutation.mutateAsync,
toggleTodo: toggleTodoMutation.mutateAsync,
updateTodo: updateTodoMutation.mutateAsync,
deleteTodo: deleteTodoMutation.mutateAsync,
isInitializing: initializeMutation.isPending,
isAdding: addTodoMutation.isPending,
};
}
Part 5: Frontend Components
TypeScript
// components/TodoList.tsx
"use client";
import { useTodo } from "@/hooks/useTodo";
import { TodoItem } from "./TodoItem";
import { AddTodo } from "./AddTodo";
export function TodoList() {
const {
todos,
isLoading,
isInitialized,
initialize,
addTodo,
toggleTodo,
updateTodo,
deleteTodo,
isInitializing,
isAdding,
} = useTodo();
if (isLoading) {
return (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
</div>
);
}
if (!isInitialized) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-medium mb-4">Create Your Todo List</h3>
<p className="text-gray-500 mb-6">
Initialize your on-chain todo list to get started
</p>
<button
onClick={() => initialize()}
disabled={isInitializing}
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 disabled:bg-gray-400"
>
{isInitializing ? "Creating..." : "Create Todo List"}
</button>
</div>
);
}
const completedCount = todos.filter((t) => t.completed).length;
return (
<div className="space-y-6">
<AddTodo onAdd={addTodo} isAdding={isAdding} />
<div className="space-y-2">
{todos.length === 0 ? (
<p className="text-center text-gray-500 py-8">
No todos yet. Add your first one above!
</p>
) : (
todos.map((todo) => (
<TodoItem
key={todo.publicKey.toBase58()}
todo={todo}
onToggle={() => toggleTodo(todo)}
onUpdate={(content) => updateTodo({ todo, content })}
onDelete={() => deleteTodo(todo)}
/>
))
)}
</div>
{todos.length > 0 && (
<div className="text-sm text-gray-500 text-center">
{todos.length} todos • {completedCount} completed •{" "}
{todos.length - completedCount} pending
</div>
)}
</div>
);
}
TypeScript
// components/TodoItem.tsx
"use client";
import { useState } from "react";
import { Pencil, Trash2, Check, X } from "lucide-react";
interface TodoItemProps {
todo: {
id: number;
content: string;
completed: boolean;
createdAt: number;
};
onToggle: () => void;
onUpdate: (content: string) => void;
onDelete: () => void;
}
export function TodoItem({ todo, onToggle, onUpdate, onDelete }: TodoItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(todo.content);
const handleSave = () => {
if (editContent.trim() && editContent !== todo.content) {
onUpdate(editContent);
}
setIsEditing(false);
};
const handleCancel = () => {
setEditContent(todo.content);
setIsEditing(false);
};
return (
<div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-900 rounded-lg border dark:border-gray-800">
{/* Checkbox */}
<button
onClick={onToggle}
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center
transition-colors ${
todo.completed
? "bg-green-500 border-green-500 text-white"
: "border-gray-300 hover:border-green-400"
}`}
>
{todo.completed && <Check className="w-4 h-4" />}
</button>
{/* Content */}
<div className="flex-1">
{isEditing ? (
<input
type="text"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
}}
className="w-full px-2 py-1 border rounded focus:ring-2 focus:ring-blue-500
dark:bg-gray-800 dark:border-gray-700"
autoFocus
/>
) : (
<span
className={`${
todo.completed ? "line-through text-gray-400" : ""
}`}
>
{todo.content}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={handleSave}
className="p-2 text-green-600 hover:bg-green-50 rounded-lg"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={handleCancel}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => setIsEditing(true)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={onDelete}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
);
}
TypeScript
// components/AddTodo.tsx
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
interface AddTodoProps {
onAdd: (content: string) => Promise<void>;
isAdding: boolean;
}
export function AddTodo({ onAdd, isAdding }: AddTodoProps) {
const [content, setContent] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
try {
await onAdd(content.trim());
setContent("");
} catch (err) {
console.error("Failed to add todo:", err);
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What needs to be done?"
maxLength={280}
className="flex-1 px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500
dark:bg-gray-900 dark:border-gray-700"
/>
<button
type="submit"
disabled={!content.trim() || isAdding}
className="px-4 py-3 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed
flex items-center gap-2"
>
<Plus className="w-5 h-5" />
{isAdding ? "Adding..." : "Add"}
</button>
</form>
);
}
Deployment Checklist
Text
┌─────────────────────────────────────────────────────────────────┐
│ Deployment Checklist │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Program: │
│ ☐ Update program ID in lib.rs │
│ ☐ Run anchor build │
│ ☐ Run anchor test │
│ ☐ Deploy to devnet: anchor deploy --provider.cluster devnet │
│ ☐ Verify on explorer │
│ │
│ Frontend: │
│ ☐ Copy IDL to frontend │
│ ☐ Update PROGRAM_ID in hooks │
│ ☐ Configure network (devnet/mainnet) │
│ ☐ Test with wallet connection │
│ ☐ Deploy to Vercel/Netlify │
│ │
│ Production: │
│ ☐ Audit program code │
│ ☐ Add rate limiting │
│ ☐ Set up monitoring │
│ ☐ Configure RPC endpoint │
│ │
└─────────────────────────────────────────────────────────────────┘
What You'll Learn
- Complete Anchor program development
- Account management with PDAs
- Frontend-program integration
- React Query for data fetching
- Transaction handling and confirmation
- Testing strategies for Solana programs
Next: DeFi App - Build a decentralized exchange.