How to create a CRUD dApp on Solana

In this guide, you will learn how to create and deploy both the Solana program and UI for a basic on-chain CRUD dApp. This dApp will allow you to create journal entries, update journal entries, read journal entries, and delete journal entries all through on-chain transactions.

What you will learn #

  • Setting up your environment
  • Using npx create-solana-dapp
  • Anchor program development
  • Anchor PDAs and accounts
  • Deploying a Solana program
  • Testing an on-chain program
  • Connecting an on-chain program to a React UI

Prerequisites #

For this guide, you will need to have your local development environment setup with a few tools:

Setting up the project #

npx create-solana-dapp

This CLI command enables quick Solana dApp creation. You can find the source code here.

Now respond to the prompts as follows:

  • Enter project name: my-journal-dapp
  • Select a preset: Next.js
  • Select a UI library: Tailwind
  • Select an Anchor template: counter program

By selecting counter for the Anchor template, a simple counter program, written in rust using the Anchor framework, will be generated for you. Before we start editing this generated template program, let's make sure everything is working as expected:

cd my-journal-dapp
 
npm install
 
npm run dev

Writing a Solana program with Anchor #

If you're new to Anchor, The Anchor Book and Anchor Examples are great references to help you learn.

In my-journal-dapp, navigate to anchor/programs/journal/src/lib.rs. There will already be template code generated in this folder. Let's delete it and start from scratch so we can walk through each step.

Define your Anchor program #

use anchor_lang::prelude::*;
 
// This is your program's public key and it will update automatically when you build the project.
declare_id!("7AGmMcgd1SjoMsCcXAAYwRgB9ihCyM8cZqjsUqriNRQt");
 
#[program]
pub mod journal {
    use super::*;
}

Define your program state #

The state is the data structure used to define the information you want to save to the account. Since Solana onchain programs do not have storage, the data is stored in accounts that live on the blockchain.

When using Anchor, the #[account] attribute macro is used to define your program state.

#[account]
#[derive(InitSpace)]
pub struct JournalEntryState {
    pub owner: Pubkey,
    #[max_len(50)]
    pub title: String,
     #[max_len(1000)]
    pub message: String,
}

For this journal dApp, we will be storing:

  • the journal's owner
  • the title of each journal entry, and
  • the message of each journal entry

Note: Space must be defined when initializing an account. The InitSpace macro used in the above code will help calculate the space needed when initializing an account. For more information on space, read here.

Create a journal entry #

Now, let's add an instruction handler to this program that creates a new journal entry. To do this, we will update the #[program] code that we already defined earlier to include an instruction for create_journal_entry.

When creating a journal entry, the user will need to provide the title and message of the journal entry. So we need to add those two variables as additional arguments.

When calling this instruction handler function, we want to save the owner of the account, the journal entry title, and the journal entry message to the account's JournalEntryState.

#[program]
mod journal {
    use super::*;
 
    pub fn create_journal_entry(
        ctx: Context<CreateEntry>,
        title: String,
        message: String,
    ) -> Result<()> {
        msg!("Journal Entry Created");
        msg!("Title: {}", title);
        msg!("Message: {}", message);
 
        let journal_entry = &mut ctx.accounts.journal_entry;
        journal_entry.owner = ctx.accounts.owner.key();
        journal_entry.title = title;
        journal_entry.message = message;
        Ok(())
    }
}

With the Anchor framework, every instruction takes a Context type as its first argument. The Context macro is used to define a struct that encapsulates accounts that will be passed to a given instruction handler. Therefore, each Context must have a specified type with respect to the instruction handler. In our case, we need to define a data structure for CreateEntry:

#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct CreateEntry<'info> {
    #[account(
        init_if_needed,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        payer = owner,
        space = 8 + JournalEntryState::INIT_SPACE
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

In the above code, we used the following macros:

  • #[derive(Accounts)] macro is used to deserialize and validate the list of accounts specified within the struct
  • #[instruction(...)] attribute macro is used to access the instruction data passed into the instruction handler
  • #[account(...)] attribute macro then specifies additional constraints on the accounts

Each journal entry is a Program Derived Address ( PDA) that stores the entries state on-chain. Since we are creating a new journal entry here, it needs to be initialized using the init_if_needed constraint.

With Anchor, a PDA is initialized with the seeds, bumps, and init_if_needed constraints. The init_if_needed constraint also requires the payer and space constraints to define who is paying the rent to hold this account's data on-chain and how much space needs to be allocated for that data.

Note: By using the InitSpace macro in the JournalEntryState, we are able to calculate space by using the INIT_SPACE constant and adding 8 to the space constraint for Anchor's internal discriminator.

Updating a journal entry #

Now that we can create a new journal entry, let's add an update_journal_entry instruction handler with a context that has an UpdateEntry type.

To do this, the instruction will need to rewrite/update the data for a specific PDA that was saved to the JournalEntryState of the account when the owner of the journal entry calls the update_journal_entry instruction.

#[program]
mod journal {
    use super::*;
 
    ...
 
    pub fn update_journal_entry(
        ctx: Context<UpdateEntry>,
        title: String,
        message: String,
    ) -> Result<()> {
        msg!("Journal Entry Updated");
        msg!("Title: {}", title);
        msg!("Message: {}", message);
 
        let journal_entry = &mut ctx.accounts.journal_entry;
        journal_entry.message = message;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct UpdateEntry<'info> {
    #[account(
        mut,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        realloc = 8 + 32 + 1 + 4 + title.len() + 4 + message.len(),
        realloc::payer = owner,
        realloc::zero = true,
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

In the above code, you should notice that it is very similar to creating a journal entry but there are a couple key differences. Since update_journal_entry is editing an already existing PDA, we do not need to initialize it. However, the message being passed to the instruction handler could have a different space size required to store it (i.e. the message could be shorter or longer), so we will need to use a few specific realloc constraints to reallocate the space for the account on-chain:

  • realloc - sets the new space required
  • realloc::payer - defines the account that will either pay or be refunded based on the newly required lamports
  • realloc::zero - defines that the account may be updated multiple times when set to true

The seeds and bump constraints are still needed to be able to find the specific PDA we want to update.

The mut constraints allows us to mutate/change the data within the account. Because how the Solana blockchain handles reading from accounts and writing to accounts differently, we must explicitly define which accounts will be mutable so the Solana runtime can correctly process them.

Note: In Solana, when you perform a reallocation, which changes the account's size, the transaction must cover the rent for the new account size. The realloc::payer = owner attribute indicates that the owner account will pay for the rent. For an account to be able to cover the rent, it typically needs to be a signer (to authorize the deduction of funds), and in Anchor, it also needs to be mutable so that the runtime can deduct the lamports to cover the rent from the account.

Delete a journal entry #

Lastly, we will add a delete_journal_entry instruction handler with a context that has a DeleteEntry type.

To do this, we will simply need to close the account for the specified journal entry.

#[program]
mod journal {
    use super::*;
 
    ...
 
    pub fn delete_journal_entry(_ctx: Context<DeleteEntry>, title: String) -> Result<()> {
        msg!("Journal entry titled {} deleted", title);
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(title: String)]
pub struct DeleteEntry<'info> {
    #[account(
        mut,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        close = owner,
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

In the above code, we use the close constraint to close out the account on-chain and refund the rent back to the journal entry's owner.

The seeds and bump constraints are needed to validate the account.

Build and deploy your Anchor program #

npm run anchor build
npm run anchor deploy

Connecting a Solana program to a UI #

create-solana-dapp already sets up a UI with a wallet connector for you. All we need to do is simply modify if to fit your newly created program.

Since this journal program has the three instructions, we will need components in the UI that will be able to call each of these instructions:

  • create entry
  • update entry
  • delete entry

Within your project's repo, open the web/components/journal/journal-data-access.tsx to add code to be able to call each of our instructions.

Update the useJournalProgram function to be able to create an entry:

const createEntry = useMutation<string, Error, CreateEntryArgs>({
  mutationKey: ["journalEntry", "create", { cluster }],
  mutationFn: async ({ title, message, owner }) => {
    const [journalEntryAddress] = await PublicKey.findProgramAddress(
      [Buffer.from(title), owner.toBuffer()],
      programId,
    );
 
    return program.methods
      .createJournalEntry(title, message)
      .accounts({
        journalEntry: journalEntryAddress,
      })
      .rpc();
  },
  onSuccess: signature => {
    transactionToast(signature);
    accounts.refetch();
  },
  onError: error => {
    toast.error(`Failed to create journal entry: ${error.message}`);
  },
});

Then update the useJournalProgramAccount function to be able to update and delete entries:

const updateEntry = useMutation<string, Error, CreateEntryArgs>({
  mutationKey: ["journalEntry", "update", { cluster }],
  mutationFn: async ({ title, message, owner }) => {
    const [journalEntryAddress] = await PublicKey.findProgramAddress(
      [Buffer.from(title), owner.toBuffer()],
      programId,
    );
 
    return program.methods
      .updateJournalEntry(title, message)
      .accounts({
        journalEntry: journalEntryAddress,
      })
      .rpc();
  },
  onSuccess: signature => {
    transactionToast(signature);
    accounts.refetch();
  },
  onError: error => {
    toast.error(`Failed to update journal entry: ${error.message}`);
  },
});
 
const deleteEntry = useMutation({
  mutationKey: ["journal", "deleteEntry", { cluster, account }],
  mutationFn: (title: string) =>
    program.methods
      .deleteJournalEntry(title)
      .accounts({ journalEntry: account })
      .rpc(),
  onSuccess: tx => {
    transactionToast(tx);
    return accounts.refetch();
  },
});

Next, update the UI in web/components/journal/journal-ui.tsx to take in user input values for the title and message of when creating a journal entry:

export function JournalCreate() {
  const { createEntry } = useJournalProgram();
  const { publicKey } = useWallet();
  const [title, setTitle] = useState("");
  const [message, setMessage] = useState("");
 
  const isFormValid = title.trim() !== "" && message.trim() !== "";
 
  const handleSubmit = () => {
    if (publicKey && isFormValid) {
      createEntry.mutateAsync({ title, message, owner: publicKey });
    }
  };
 
  if (!publicKey) {
    return <p>Connect your wallet</p>;
  }
 
  return (
    <div>
      <input
        type="text"
        placeholder="Title"
        value={title}
        onChange={e => setTitle(e.target.value)}
        className="input input-bordered w-full max-w-xs"
      />
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
        className="textarea textarea-bordered w-full max-w-xs"
      />
      <br></br>
      <button
        type="button"
        className="btn btn-xs lg:btn-md btn-primary"
        onClick={handleSubmit}
        disabled={createEntry.isPending || !isFormValid}
      >
        Create Journal Entry {createEntry.isPending && "..."}
      </button>
    </div>
  );
}

Lastly, update the UI in journal-ui.tsx to take in a user input values for the message of when updating a journal entry:

function JournalCard({ account }: { account: PublicKey }) {
  const { accountQuery, updateEntry, deleteEntry } = useJournalProgramAccount({
    account,
  });
  const { publicKey } = useWallet();
  const [message, setMessage] = useState("");
  const title = accountQuery.data?.title;
 
  const isFormValid = message.trim() !== "";
 
  const handleSubmit = () => {
    if (publicKey && isFormValid && title) {
      updateEntry.mutateAsync({ title, message, owner: publicKey });
    }
  };
 
  if (!publicKey) {
    return <p>Connect your wallet</p>;
  }
 
  return accountQuery.isLoading ? (
    <span className="loading loading-spinner loading-lg"></span>
  ) : (
    <div className="card card-bordered border-base-300 border-4 text-neutral-content">
      <div className="card-body items-center text-center">
        <div className="space-y-6">
          <h2
            className="card-title justify-center text-3xl cursor-pointer"
            onClick={() => accountQuery.refetch()}
          >
            {accountQuery.data?.title}
          </h2>
          <p>{accountQuery.data?.message}</p>
          <div className="card-actions justify-around">
            <textarea
              placeholder="Update message here"
              value={message}
              onChange={e => setMessage(e.target.value)}
              className="textarea textarea-bordered w-full max-w-xs"
            />
            <button
              className="btn btn-xs lg:btn-md btn-primary"
              onClick={handleSubmit}
              disabled={updateEntry.isPending || !isFormValid}
            >
              Update Journal Entry {updateEntry.isPending && "..."}
            </button>
          </div>
          <div className="text-center space-y-4">
            <p>
              <ExplorerLink
                path={`account/${account}`}
                label={ellipsify(account.toString())}
              />
            </p>
            <button
              className="btn btn-xs btn-secondary btn-outline"
              onClick={() => {
                if (
                  !window.confirm(
                    "Are you sure you want to close this account?",
                  )
                ) {
                  return;
                }
                const title = accountQuery.data?.title;
                if (title) {
                  return deleteEntry.mutateAsync(title);
                }
              }}
              disabled={deleteEntry.isPending}
            >
              Close
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

Resources #