Build a Todo App on Solana with Anchor and Rust

Building a todo app on Solana using the Anchor framework and Rust

A todo app is the classic way to learn any new stack, and Solana is no different. It touches everything that matters — creating accounts, storing data, updating state, handling authorization, and cleaning up when you're done. When I was building the Anchor smart contracts for Moonly, these were exactly the patterns I needed to understand first.

In this guide we'll build a complete todo program with five instructions: create an account, add a task, toggle its completion status, remove a task, and close the account to reclaim your SOL. We'll also write TypeScript tests for the full lifecycle.

Prerequisites: You need Rust, Solana CLI, Anchor, and Node.js installed. If you haven't set these up, follow my Solana development setup guide. If you're new to deploying Solana programs, read the deploy guide first — it covers the build-deploy-verify workflow we'll use here.

How Solana Stores Data

Before writing any code, it helps to understand how data works on Solana — because it's fundamentally different from a traditional database.

On Solana, everything is an account. Programs are accounts. Wallets are accounts. Your todo list will be an account too. Each account holds a blob of bytes, and your program decides how to interpret those bytes (what fields they represent, how they're laid out in memory).

If you're coming from Web2, think of it this way: in a traditional API, you'd have a database table for todos and your server reads/writes rows. On Solana, each user gets their own "row" — an account — and your program defines the schema and the operations allowed on it.

One important difference: all data on Solana is public. Anyone can read any account's data. That's why our todo app has no "Read" instruction — clients just fetch the account data directly. Our program only needs instructions that write data: create, add, toggle, remove, and close.

The other concept you'll see a lot is PDAs (Program Derived Addresses). A PDA is a deterministic address derived from a set of seeds — in our case, the string "todo" and the user's wallet address. This means each user gets exactly one todo account, and we can always find it by re-deriving the address from the same seeds. No database lookups, no UUIDs.

Project Setup

Create a new Anchor project and navigate into it:

anchor init solana-todo
cd solana-todo

Update Anchor.toml to target devnet:

[provider]
cluster = "devnet"
wallet = "~/.config/solana/id.json"

If you followed my deploy guide, this workflow should be familiar. We'll build, sync the program ID, and deploy at the end.

Designing the Account

Our todo program needs two data structures: a Task (a single todo item) and a TodoAccount (the user's account that holds all their tasks). Open programs/solana-todo/src/lib.rs and start with the structs:

#[account]
pub struct TodoAccount {
    pub owner: Pubkey,        // 32 bytes — who owns this account
    pub last_id: u8,          // 1 byte  — auto-incrementing task ID
    pub todos: Vec<Task>,     // 4 bytes (length prefix) + dynamic
}

#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct Task {
    pub id: u8,               // 1 byte  — unique identifier
    pub text: String,         // 4 bytes (length prefix) + content
    pub is_done: bool,        // 1 byte  — completion status
}

The #[account] macro tells Anchor to handle serialization and add an 8-byte discriminator (a hash that identifies the account type). The Task struct uses #[derive(AnchorSerialize, AnchorDeserialize, Clone)] instead because it's not a standalone account — it's nested data inside TodoAccount.

Now let's talk about space. When you create an account on Solana, you must specify its size upfront. Here's how to calculate it:

8 bytes account discriminator (Anchor adds this automatically)
32 bytes owner (Pubkey)
1 byte last_id (u8)
4 bytes todos Vec length prefix
= 45 bytes base size

Each Task takes 1 (id) + 4 (string length prefix) + text length + 1 (is_done) = 6 + text length bytes. Since todos are dynamic, we'll start with just the base size and use Anchor's realloc constraint to grow the account as tasks are added. This means users only pay for the space they actually use.

Instruction 1: Create Account

The first instruction initializes a user's todo account. This is where PDAs come in — we derive the account address from the user's wallet so each user gets exactly one todo account:

#[derive(Accounts)]
pub struct CreateAccount<'info> {
    #[account(
        init,
        payer = owner,
        space = 8 + 32 + 1 + 4, // discriminator + owner + last_id + vec prefix
        seeds = [b"todo", owner.key().as_ref()],
        bump,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Let's break down the constraints:

init — creates the account. This can only be called once per PDA (calling it again would fail because the account already exists).

payer = owner — the user pays for the account's rent (storage cost). On Solana, accounts need a minimum SOL balance to exist — this is called "rent exemption."

space = 8 + 32 + 1 + 4 — 45 bytes total. We allocate only the base size and grow later with realloc.

seeds = [b"todo", owner.key().as_ref()] — the PDA seeds. The combination of "todo" + the user's public key deterministically produces this account's address.

bump — Anchor finds and stores the PDA bump seed automatically.

The instruction function is simple — just set the owner:

pub fn create_account(ctx: Context<CreateAccount>) -> Result<()> {
    let todo_account = &mut ctx.accounts.todo_account;
    todo_account.owner = *ctx.accounts.owner.key;
    Ok(())
}

Instruction 2: Add Todo

This is where it gets interesting. When a user adds a task, the account needs to grow to fit the new data. Anchor's realloc constraint handles this:

#[derive(Accounts)]
#[instruction(text: String)]
pub struct AddTodo<'info> {
    #[account(
        mut,
        realloc = todo_account.to_account_info().data_len() + 4 + text.len() + 1 + 1,
        realloc::payer = owner,
        realloc::zero = false,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

The #[instruction(text: String)] attribute gives us access to the instruction's arguments inside the accounts struct — we need the text length to calculate the new size.

realloc — resizes the account. The new size is the current size plus the space for one Task: 4 bytes (string length prefix) + text length + 1 byte (id) + 1 byte (is_done).

realloc::payer = owner — the user pays for the additional space. If the account shrinks later, SOL gets refunded back to the payer.

realloc::zero = false — don't zero out the existing data when resizing. We want to keep the tasks that are already there.

has_one = owner — verifies that the owner field in the account matches the signer. This is authorization — only the account owner can add tasks.

The instruction function creates the task and appends it:

pub fn add_todo(ctx: Context<AddTodo>, text: String) -> Result<()> {
    let todo_account = &mut ctx.accounts.todo_account;
    todo_account.last_id = todo_account.last_id.checked_add(1).unwrap();
    todo_account.todos.push(Task {
        id: todo_account.last_id,
        text,
        is_done: false,
    });
    Ok(())
}

We use checked_add to safely increment the ID counter — it will panic instead of silently overflowing if someone somehow adds more than 255 tasks (u8 max). In a production program you'd want a custom error for that, but for a tutorial this is fine.

Instruction 3: Toggle Todo

Toggling a task's completion status doesn't change the account size, so we don't need realloc — just mut to allow writes:

#[derive(Accounts)]
pub struct ToggleTodo<'info> {
    #[account(
        mut,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    pub owner: Signer<'info>,
}

Notice the owner doesn't need #[account(mut)] here — we aren't charging them any SOL for this operation. The account size stays the same.

pub fn toggle_todo(ctx: Context<ToggleTodo>, todo_id: u8) -> Result<()> {
    let todo_account = &mut ctx.accounts.todo_account;
    let task = todo_account
        .todos
        .iter_mut()
        .find(|t| t.id == todo_id)
        .ok_or(ErrorCode::TodoNotFound)?;
    task.is_done = !task.is_done;
    Ok(())
}

We find the task by ID and flip its is_done boolean. If the task doesn't exist, we return a custom error. We'll define that error code shortly.

Instruction 4: Remove Todo

Removing a task is the inverse of adding one — the account shrinks. We need to calculate the new size based on the task being removed:

#[derive(Accounts)]
#[instruction(todo_id: u8)]
pub struct RemoveTodo<'info> {
    #[account(
        mut,
        realloc = todo_account.to_account_info().data_len()
            - (4 + todo_account.todos.iter().find(|t| t.id == todo_id).unwrap().text.len() + 1 + 1),
        realloc::payer = owner,
        realloc::zero = false,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

The realloc size subtracts the space used by the task being removed. We use #[instruction(todo_id: u8)] to access the todo ID in the accounts struct so we can find the task and calculate its size. The owner needs #[account(mut)] here because they'll receive the refunded rent.

pub fn remove_todo(ctx: Context<RemoveTodo>, todo_id: u8) -> Result<()> {
    let todo_account = &mut ctx.accounts.todo_account;
    let index = todo_account
        .todos
        .iter()
        .position(|t| t.id == todo_id)
        .ok_or(ErrorCode::TodoNotFound)?;
    todo_account.todos.remove(index);
    Ok(())
}

Instruction 5: Close Account

When a user is done with their todo list, they can close the account entirely to reclaim all the SOL locked up in rent. Anchor's close constraint handles this in one line:

#[derive(Accounts)]
pub struct CloseAccount<'info> {
    #[account(
        mut,
        close = owner,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
}

close = owner does three things: transfers all lamports (SOL) from the account to the owner, zeros out the account data, and sets the account's owner to the system program — effectively deleting it. After this, the user could create a new todo account with the same PDA by calling CreateAccount again.

pub fn close_account(_ctx: Context<CloseAccount>) -> Result<()> {
    // Anchor's close constraint handles everything
    Ok(())
}

Custom Errors

Our toggle and remove instructions reference a TodoNotFound error. Define it with Anchor's error_code macro:

#[error_code]
pub enum ErrorCode {
    #[msg("Todo with the given ID was not found.")]
    TodoNotFound,
}

Anchor automatically converts this into an error that clients can catch and display. The error code gets a unique numeric ID that shows up in transaction logs.

The Complete Program

Here's the full lib.rs with all five instructions, the account structs, and the error code. Replace everything in programs/solana-todo/src/lib.rs with this:

use anchor_lang::prelude::*;

declare_id!("YOUR_PROGRAM_ID_HERE");

#[program]
pub mod solana_todo {
    use super::*;

    pub fn create_account(ctx: Context<CreateAccount>) -> Result<()> {
        let todo_account = &mut ctx.accounts.todo_account;
        todo_account.owner = *ctx.accounts.owner.key;
        Ok(())
    }

    pub fn add_todo(ctx: Context<AddTodo>, text: String) -> Result<()> {
        let todo_account = &mut ctx.accounts.todo_account;
        todo_account.last_id = todo_account.last_id.checked_add(1).unwrap();
        todo_account.todos.push(Task {
            id: todo_account.last_id,
            text,
            is_done: false,
        });
        Ok(())
    }

    pub fn toggle_todo(ctx: Context<ToggleTodo>, todo_id: u8) -> Result<()> {
        let todo_account = &mut ctx.accounts.todo_account;
        let task = todo_account
            .todos
            .iter_mut()
            .find(|t| t.id == todo_id)
            .ok_or(ErrorCode::TodoNotFound)?;
        task.is_done = !task.is_done;
        Ok(())
    }

    pub fn remove_todo(ctx: Context<RemoveTodo>, todo_id: u8) -> Result<()> {
        let todo_account = &mut ctx.accounts.todo_account;
        let index = todo_account
            .todos
            .iter()
            .position(|t| t.id == todo_id)
            .ok_or(ErrorCode::TodoNotFound)?;
        todo_account.todos.remove(index);
        Ok(())
    }

    pub fn close_account(_ctx: Context<CloseAccount>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct CreateAccount<'info> {
    #[account(
        init,
        payer = owner,
        space = 8 + 32 + 1 + 4,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(text: String)]
pub struct AddTodo<'info> {
    #[account(
        mut,
        realloc = todo_account.to_account_info().data_len() + 4 + text.len() + 1 + 1,
        realloc::payer = owner,
        realloc::zero = false,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct ToggleTodo<'info> {
    #[account(
        mut,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    pub owner: Signer<'info>,
}

#[derive(Accounts)]
#[instruction(todo_id: u8)]
pub struct RemoveTodo<'info> {
    #[account(
        mut,
        realloc = todo_account.to_account_info().data_len()
            - (4 + todo_account.todos.iter().find(|t| t.id == todo_id).unwrap().text.len() + 1 + 1),
        realloc::payer = owner,
        realloc::zero = false,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct CloseAccount<'info> {
    #[account(
        mut,
        close = owner,
        seeds = [b"todo", owner.key().as_ref()],
        bump,
        has_one = owner,
    )]
    pub todo_account: Account<'info, TodoAccount>,
    #[account(mut)]
    pub owner: Signer<'info>,
}

#[account]
pub struct TodoAccount {
    pub owner: Pubkey,
    pub last_id: u8,
    pub todos: Vec<Task>,
}

#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct Task {
    pub id: u8,
    pub text: String,
    pub is_done: bool,
}

#[error_code]
pub enum ErrorCode {
    #[msg("Todo with the given ID was not found.")]
    TodoNotFound,
}

After pasting this, run anchor build, sync your program ID with anchor keys list, update the declare_id! and Anchor.toml, then rebuild. See the deploy guide for the full workflow.

Writing Tests

A program isn't done until it's tested. Replace the content of tests/solana-todo.ts with tests that cover the full lifecycle — create, add, toggle, remove, and close:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SolanaTodo } from "../target/types/solana_todo";
import { assert } from "chai";

describe("solana-todo", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.solanaTodo as Program<SolanaTodo>;
  const owner = provider.wallet;

  const [todoAccountPDA] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("todo"), owner.publicKey.toBuffer()],
    program.programId
  );

  it("creates a todo account", async () => {
    await program.methods
      .createAccount()
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();

    const account = await program.account.todoAccount.fetch(todoAccountPDA);
    assert.equal(account.owner.toBase58(), owner.publicKey.toBase58());
    assert.equal(account.lastId, 0);
    assert.equal(account.todos.length, 0);
  });

  it("adds a todo", async () => {
    await program.methods
      .addTodo("Learn Anchor")
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();

    const account = await program.account.todoAccount.fetch(todoAccountPDA);
    assert.equal(account.todos.length, 1);
    assert.equal(account.todos[0].text, "Learn Anchor");
    assert.equal(account.todos[0].isDone, false);
    assert.equal(account.todos[0].id, 1);
  });

  it("adds a second todo", async () => {
    await program.methods
      .addTodo("Deploy to devnet")
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();

    const account = await program.account.todoAccount.fetch(todoAccountPDA);
    assert.equal(account.todos.length, 2);
    assert.equal(account.todos[1].text, "Deploy to devnet");
    assert.equal(account.todos[1].id, 2);
  });

  it("toggles a todo", async () => {
    await program.methods
      .toggleTodo(1)
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
      })
      .rpc();

    const account = await program.account.todoAccount.fetch(todoAccountPDA);
    assert.equal(account.todos[0].isDone, true);
  });

  it("toggles the todo back", async () => {
    await program.methods
      .toggleTodo(1)
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
      })
      .rpc();

    const account = await program.account.todoAccount.fetch(todoAccountPDA);
    assert.equal(account.todos[0].isDone, false);
  });

  it("removes a todo", async () => {
    await program.methods
      .removeTodo(1)
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();

    const account = await program.account.todoAccount.fetch(todoAccountPDA);
    assert.equal(account.todos.length, 1);
    assert.equal(account.todos[0].text, "Deploy to devnet");
  });

  it("closes the account", async () => {
    await program.methods
      .closeAccount()
      .accounts({
        todoAccount: todoAccountPDA,
        owner: owner.publicKey,
      })
      .rpc();

    const account = await program.provider.connection.getAccountInfo(
      todoAccountPDA
    );
    assert.isNull(account);
  });
});

Run the tests against devnet (or a local validator):

anchor test --skip-local-validator

Each test builds on the previous one — we create an account, add two tasks, toggle the first one on and off, remove it, and finally close the entire account. The last test verifies the account no longer exists by checking that getAccountInfo returns null.

Notice how the TypeScript types (SolanaTodo) are generated from your IDL during anchor build. You get full type safety — method names, account fields, and error codes are all typed. If you rename an instruction in Rust, the TypeScript code will fail to compile until you update it.

What You've Learned

We built a complete CRUD program on Solana — not just a hello world, but a real application pattern with account creation, dynamic data, authorization, and cleanup. Here's what each concept gives you:

PDAs — deterministic addresses that map users to their data. No database, no UUIDs, just seeds and a derivation function.

realloc — dynamic account sizing. Pay for what you use, get refunded when you shrink. Essential for any program with variable-length data.

close — account cleanup. Users reclaim their SOL when they're done. This matters more than you think — rent adds up across thousands of accounts.

has_one — authorization. Making sure only the owner can modify their data. Simple but critical.

These are the same patterns I used when building the staking and reward distribution programs for Moonly. The jump from a todo app to a production program is mostly about adding more instructions, more account validation, and more edge-case handling — but the fundamentals are all here.