Hey there, today we are going to be learning how to create the CRUD operations in Web3 by building a simple Blog DApp. In this tutorial we will:
- Build a Solidity smart contract that will hold the blog's content and allow users to interact with it.
- Deploy the smart contract to the Mumbai TestNet, and perform the Web3 interactions on the Frontend using Thirdweb.
- Build the frontend using Nextjs.
- Style our blog using Tailwind CSS.
By the end of that tutorial, you'll have a full-stack CRUD DApp, that you can build your Web3 knowledge upon, whether you are new to the technology, or just need to refresh your basics.
Without further ado, let's dive right in and get started!
Let's start by opening up the terminal and creating a new directory for our project
mkdir Next3Blog && cd Next3Blog
Here we are calling it Next3Blog but you can call it whatever you want.
After that, we are going to create a new Thirdweb project, that will contain our smart contract logic
Run the following command
npx thirdweb@latest create --contract
It should prompt a few questions that will help setup the environment based on our needs
I set the project name to web3, picked Hardhat as our development environment framework, set BlogContract as the name of our smart contract, and finally, chose to initiate the project with an empty contract.
Navigate to the newly generated folder and take a look at the directory structure
cd web3
After navigating to the project folder, you will find the contracts directory which contains a single smart contract.
Building a Smart Contract using Solidity:
Solidity is a powerful high-level programming language. It's very similar to JavaScript, but it's essentially designed and used to write smart contracts on the Ethereum blockchain.
Open the contract file and copy the following code into it
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract BlogContract {
struct Post {
uint postId;
address owner;
string postContent;
bool isDeleted;
}
mapping(uint => address) blogOwners;
Post[] private posts;
}
We start by specifying the compiler version for the smart contract.
Then, we declare Post struct as a custom data type for our blog posts. It contains four variables, postId as uint which represents an unsigned (positive) integer, postContent as a string, isDeleted as a boolean.
mapping
is a hash table that allows us to associate a key with a value, so in the above code we define a blogOwners mapping of a unit key, and address value.
After that, we declare a private posts array of type Post. The private keyword makes the array inaccessible from outside the contract.
Adding posts:
function addPost(string memory postContent) external {
uint postId = posts.length;
address owner = msg.sender;
posts.push(Post(postId, owner, postContent, false));
blogOwners[postId] = owner;
}
In Solidity, a function parameter must be declared with a specific data type since it's a typed language.
In the above code, we are defining the addPost function, with a string parameter. memory
is used to store data temporarily during the execution of a function, and will be deleted from memory once the function returns.
external
makes the function visible from outside the contract only, and cannot be called from within the contract itself.
We then assign a couple of variables, postId of type unit, and owner of type address.
msg.sender
refers to the address that called the function.
After that, we use the Post struct to define the post variables and push it to the posts array. And finally, we map the postId to the blog owner address.
In Solidity, events
are used as a way to notify external systems or contracts about specific actions that occur within a contract.
Let's assign the AddPost event that will serve as a notifier when a new post is registered.
contract BlogContract {
event AddPost(address owner, uint postId);
...
}
Now we add emit to the addPost function to trigger the event.
function addPost(string memory postContent) external {
...
emit AddPost(owner, postId);
}
Deleting posts:
function deletePost(uint postId) external {
require(blogOwners[postId] == msg.sender, "You are not the owner of this post");
posts[postId].isDeleted = true;
}
Here we are defining a function that takes a single parameter postId of type uint, and then checking if the caller address is the owner of the post. require
checks for the validity of the condition and it will revert the transaction and display the error message that we set.
Once data is written to the blockchain, it can’t be deleted as it becomes a permanent part of the blockchain’s history. However, we can "soft-delete" it. Remember the variable isDeleted that we added above? We are initially assigning it to false, and we will simply modify it to be true, which will create an illusion of deleting it.
Later on, when implementing the getPosts function we will filter the data, and only return data with the isDeleted set to false.
Let's now declare the DeletePost event
contract BlogContract {
...
event DeletePost(uint postId, bool isDeleted);
...
function deletePost(uint postId) external {
...
emit DeletePost(postId, true);
}
}
Getting posts:
function getPosts() external view returns (Post[] memory){
Post[] memory temporary = new Post[](posts.length);
uint counter = 0;
for (uint i = 0; i < posts.length; i++) {
if (posts[i].isDeleted == false) {
temporary[counter] = posts[i];
counter++;
}
}
Post[] memory result = new Post[](counter);
for (uint i = 0; i < counter; i++) {
result[i] = temporary[i];
}
return result;
}
A view
function allows read-only access to the data stored in the contract and since it doesn't modify the state of the contract or blockchain, it doesn't require any transaction to be sent to the blockchain making it a lightweight operation.
As I mentioned earlier, we are gonna loop through the posts array, and only return posts with the isDeleted set to false.
First, we declare a temporary array of type Post, with a size equal to the length of the posts array.
Note that when declaring an array with the memory keyword, you need to specify the size of the array because Solidity doesn't support dynamic array resizing in memory.
Then we assign a counter variable that will track the number of the non "deleted" posts.
Then we loop through the posts array and store the non deleted posts inside the temporary array.
Since the temporary array may still have extra slots for the deleted posts, we need to declare another array of length counter to ensure that the final result array is of the exact size of the non-deleted posts.
Now that we finished implementing the contract, we need to deploy it to the blockchain. In the project directory you will find hardhat.config.js
, this file contains all of our configuration settings of our development environment.
Navigate to the file and copy the code below into it:
module.exports = {
solidity: {
version: '0.8.9',
defaultNetwork: 'mumbai',
networks: {
hardhat: {},
mumbai: {
url: 'https://rpc.ankr.com/polygon_mumbai',
accounts: [`0x${process.env.PRIVATE_KEY}`]
}
},
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
};
Here we are specifying the settings for the Hardhat project.
We declare the version of the compiler, the defaultNetwork as the Mumbai Testnet and defining the network configurations, we specify the URL of the RPC endpoint "https://rpc.ankr.com/polygon_mumbai" and the account to use for deployment.
optimizer
is a tool that tries to reduce the gas cost of a smart contract by optimizing its bytecode, and setting the optimizer to 200 means that it will run the optimizer for a maximum of 200 times. The more times the optimizer runs, the more aggressive it is in optimizing the bytecode, but it also takes longer to compile the contract.
Now head to your browser and download the MetaMask wallet extension.
Follow the instructions to create a new wallet and don't forget to save the secret recovery phrase.
After you successfully set up your wallet, head to the extension and add the Mumbai TestNet to your networks.
- Network name: Mumbai TestNet
- New RPC URL: https://rpc.ankr.com/polygon_mumbai
- Chain ID: 80001
- Currency symbol: MATIC
Now copy the private key as shown here. That will give access to the wallet account and enable us to deploy the contract using it
For security purposes we need to add the private key into .env
file
We need to install dotenv
to be able to load and manage the environment variables from the .env
file into the process environment.
npm install dotenv --save
When deploying the contract, it requires a certain amount of MATIC to cover the transaction fees.
Let's now get some MATIC faucet (https://faucet.polygon.technology/)
Head to https://faucet.polygon.technology and copy your wallet address into the input, click submit, and you should get a small amount of MATIC in a few moments.
This should be sufficient to deploy our contract.
Now open your terminal and run the deployment command
npm run deploy
This is a thirdweb script that will detect any contract inside the directory and compile it, upload the ABI to IPFS and generate a deployment link.
Open the link in your browser and click the deploy button to deploy the contract to the blockchain
Thirdweb will generate a dashboard to manage and interact with the deployed contract conveniently. Copy the contract address and put it to the side now, we are gonna use it later when we move on to the Frontend part.
Navigate to Explorer and play around with the contract functions
Nextjs
Next.js is an open-source React framework that provides server-side rendering, and makes it easier to build and optimize complex web applications.
We'll start by setting up our development environment and create a Nextjs app
Navigate to the root directory and copy the command below into your terminal
npx create-next-app@latest client
You will be prompted with a bunch of questions in the terminal
After it finishes installing the project dependencies, navigate to the project directory.
Tailwind CSS
Tailwind CSS is a utility-first CSS framework and a design system that provides you with pre-built classes to style your elements and create consistent and responsive web pages.
So, let's say you have a button element and you want to change its background color to gray, you can simply add bg-gray-500 - Assigning the value as 500 means that the color is gonna have a mid-level shade.
Setting up Tailwind CSS:
Let's first install the Tailwind CSS dependencies
npm install -D tailwindcss postcss autoprefixer
PostCSS and autoprefixer apply additional optimizations and ensure that the generated classes work on all browsers without worrying about the compatibility issues.
Initialize Tailwind CSS in our project
npx tailwindcss init -p
That will generate two files npm tailwind.config.js
and postcss.config.js
.
Inside tailwind.config.js
copy the code below
module.exports = {
content: [
"./src/**/*.{js,jsx}",
],
theme: {
extend: {
fontFamily: {
openSans: ['Open Sans', 'sans-serif']
}
},
}
}
Here we are adding the paths to all of the expected template files to contain Tailwind styles, and the font we're gonna use in the project.
Then you navigate to ./src/styles/globals.css and add the Tailwind directives and the link for the font.
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
Now we are ready to start building our UI
Here is a closer look to the final view of what we are building
In the pages directory, you will find index.js
. This will be our main page.
In the src directory create components directory, and inside of it create four new files Navbar.js
, Form.js
, Post.js
, and index.js
.
Add a utils folder and inside of it create index.js
By now the src directory tree should look like this
Copy the code below into ./pages/index.js
export default function App() {
return (
<div className="bg-[#eff2f5] flex flex-col min-h-screen">
</div>
)
}
As you can see, the tailwind classes that we are adding here are self-explanatory, we are simply setting the background color to #eff2f5, the display to flex, the flex direction to flex column, and the min height to 100vh.
Let's install an additional package before we implement the Navbar
npm i react-identicons --save
Identicons provide a visual representation to wallet addresses. They are typically generated using an algorithm that takes the wallet address as input and produces a unique image based on that input.
Navigate to Navbar.js
and copy the following code into it
import Identicon from 'react-identicons';
export default function Navbar() {
return (
<div className="bg-white">
<div className="px-5 py-2 flex items-center justify-between shadow-md">
<h2 className="font-openSans font-bold text-gray-600 font-medium">
Next3Blog
</h2>
<div className="border border-[#c7cacd] text-gray-600 px-2 py-1 rounded-[12px] inline-flex items-center">
<div className="w-8 h-8 mr-2 rounded-full overflow-hidden border border-[#c7cacd] flex justify-center ">
<Identicon string={"0x34710CdeC3e80A174cb384870B3Ff2854d5556ce"} size={30}/>
</div>
<h2 className="font-openSans text-black text-[12px]">0x34710CdeC3e80A174cb384870B3Ff2854d5556ce</h2>
</div>
</div>
</div>
)
}
Notice how we are using the font that we previously declared in the tailwind.config.js
.
To save space, and make the UI more compact, let's shorten the wallet address that we are displaying on the Navbar. This is a convention in DApps to represent the address in a user-friendly way.
Navigate to ./utils/index.js
and add the following function
export const truncateAddress = (address) => {
return address.substring(0, 6) + '...' + address.substring(address.length - 4, address.length)
};
This truncates the address to show the first 6 characters and the last 4 characters, with an ellipsis (...) in between.
Let's import the function in Navbar.js
and call the function with the temporary address.
...
<h2 className="font-openSans text-black text-[12px]">{truncateAddress(address)}</h2>
...
Next, navigate to Form.js
import Identicon from 'react-identicons';
export default function Form() {
return (
<form className="lg:w-1/2 flex flex-col w-full h-full bg-white overflow-hidden mb-[50px] shadow-md rounded-[8px] py-5 px-10 box-border">
<div className="w-full inline-flex justify-between items-center mb-4">
<div className="w-12 h-12 rounded-full overflow-hidden border border-[#c7cacd] flex justify-center ">
<Identicon string={"0x34710CdeC3e80A174cb384870B3Ff2854d5556ce"} size={45}/>
</div>
<div className="w-4/5 lg:w-[90%]">
<input type="text" id="post" className="font-openSans w-full p-3 text-md border rounded-[22px] focus:outline-none focus:shadow-outline" placeholder="Share Your Thoughts and Ideas!" />
</div>
</div>
<div className="w-full justify-center items-center">
<button className="w-full bg-[#1974E7] px-2 py-2 rounded-[6px] flex justify-center cursor-pointer disabled:opacity-50 disabled:cursor-default" type='submit'>
<h2 className='font-openSans text-white transition'>Publish</h2>
</button>
</div>
</form>
)
}
w-4/5: Sets the width to 75%
lg:w-[90%]: Sets the width to 90% on large screens
Navigate to Post.js
import Identicon from 'react-identicons';
import { truncateAddress } from '@/utils';
export default function Post() {
return (
<div className="lg:w-1/2 flex flex-col w-full h-full bg-white overflow-hidden mb-6 shadow-md rounded-[8px] pt-2 pb-5 px-6 box-border">
<div className="w-full inline-flex justify-between items-center mb-4">
<div className="inline-flex items-center">
<div className="w-[40px] h-[40px] flex justify-center rounded-full overflow-hidden flex-shrink-0 border border-[#c7cacd]">
<Identicon string={"0x34710CdeC3e80A174cb384870B3Ff2854d5556ce"} size={35} />
</div>
<div className="flex-1 pl-2">
<h2 className="font-openSans text-black mb-1">{truncateAddress("0x34710CdeC3e80A174cb384870B3Ff2854d5556ce")}</h2>
</div>
</div>
<button className="w-[10px] cursor-pointer">
<svg className="fill-current w-3 h-3" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="122.878px" height="122.88px" viewBox="0 0 122.878 122.88" enableBackground="new 0 0 122.878 122.88" xml-space="preserve">
<g>
<path d="M1.426,8.313c-1.901-1.901-1.901-4.984,0-6.886c1.901-1.902,4.984-1.902,6.886,0l53.127,53.127l53.127-53.127 c1.901-1.902,4.984-1.902,6.887,0c1.901,1.901,1.901,4.985,0,6.886L68.324,61.439l53.128,53.128c1.901,1.901,1.901,4.984,0,6.886 c-1.902,1.902-4.985,1.902-6.887,0L61.438,68.326L8.312,121.453c-1.901,1.902-4.984,1.902-6.886,0 c-1.901-1.901-1.901-4.984,0-6.886l53.127-53.128L1.426,8.313L1.426,8.313z"></path>
</g>
</svg>
</button>
</div>
<div className="w-full justify-center items-center">
<p className='font-openSans'>Sample Blog post</p>
</div>
</div>
)
}
Now to better organize our components export them in the ./components/index.js file
export { default as Navbar } from './Navbar';
export { default as Form } from './Form';
export { default as Post } from './Post';
After that, we import our components to our main page ./pages/index.js
...
import { Navbar, Form, Post } from "@/components";
...
return (
<div className="bg-[#eff2f5] flex flex-col min-h-screen">
<Navbar />
<div className="pt-12 pb-6 flex flex-col items-center justify-center">
<Form />
<Post />
</div>
</div>
)
}
Thirdweb Integrations:
Now that we are done creating the base UI for our app. Let's start the Web3 integrations to be able to interact with our contract
Install the dependencies
npm i @thirdweb-dev/sdk @thirdweb-dev/react --save
Navigate to ./pages/_app.js and copy the following code into it
import Head from 'next/head';
import { ChainId, ThirdwebProvider } from '@thirdweb-dev/react';
import '@/styles/globals.css'
export default function App({ Component, pageProps }) {
return (
<ThirdwebProvider network={ChainId.Mumbai}>
<Head>
<title>Next3Blog</title>
</Head>
<Component {...pageProps} />
</ThirdwebProvider>
)
}
Here we are wrapping the app with the Thirdweb provider, and setting the network to the Mumbai chain id.
Inside ./pages/index.js copy the following code
import { useMetamask, useAddress } from "@thirdweb-dev/react";
export default function App() {
const connect = useMetamask();
const address = useAddress();
We import a couple of hooks provided by Thirdweb, we're gonna need a useMetamask to connect the app to Metamask wallet and useAddress that gets the connected address
Then we pass both hooks to the Navbar component and address to the Form and Post.
...
<div className="bg-[#eff2f5] flex flex-col min-h-screen">
<Navbar
connect={connect}
address={address}
/>
<div className="pt-12 pb-6 flex flex-col items-center justify-center">
<Form
address={address}
/>
<Post
address={address}
/>
</div>
</div>
...
Let's implement the connect wallet functionality in the Navbar
export default function Navbar({ connect, address }) {
return (
<div className="bg-white">
...
{ address ?
<div className="border border-[#c7cacd] text-gray-600 px-2 py-1 rounded-[12px] inline-flex items-center">
<div className="w-8 h-8 mr-2 rounded-full overflow-hidden border border-[#c7cacd] flex justify-center ">
<Identicon string={address} size={30}/>
</div>
<h2 className="font-openSans text-black text-[12px]">{truncateAddress(address)}</h2>
</div> :
<button className="group/item border border-[#c7cacd] text-gray-600 px-2 py-1 rounded-[12px] inline-flex items-center cursor-pointer hover:bg-[#1974E7] hover:text-white transition"
onClick={() => connect()}
>
<h2 className='font-openSans group-hover/item:text-white text-[#1974E7] transition'>Connect Wallet</h2>
</button>
}
...
</div>
)
}
Here we are doing conditional rendering, so we show the address if there is a connected address, or else show the connect button
Back to index.js
We import useContract to be able to connect to our contract and pass our smart contract address to it.
import { ..., useContract } from "@thirdweb-dev/react";
...
export default function App() {
...
const { contract } = useContract("0x1e55A7D8c854c239cD30EA737700D1C7d7273395");
Now let's do our first contract call
...
const [posts, setPosts] = useState([]);
...
const getPosts = async () => {
try {
const posts = await contract.call('getPosts');
const parsedPosts = posts.map((post, i) => ({
owner: post.owner,
postContent: post.postContent,
id: post.postId.toNumber()
}));
setPosts(parsedPosts);
}
catch (err) {
console.error("contract call failure", err);
}
}
Thirdweb makes it extremely simple to integrate our smart contract functions.
Here we are calling the getPosts function and then parsing through the returned posts array to get the values that we need. If you haven’t played around on Thirdweb dashboard and created a post, this should be returning an empty array.
Then we call the function inside useEffect to run whenever the component mounts
useEffect(() => {
if(contract) getPosts();
}, [address, contract]);
we check if the contract value exists before calling getPosts, and re-runs whenever the address or contract values is updated.
Next, we map over the posts array to return a list of Post components.
{posts.map(post =>
<Post
key={post.id}
id={post.id}
owner={post.owner}
address={address}
postContent={post.postContent}
/>)
}
Before we implement the create post functionality we need to keep track of the form input value, let’s declare a new useState variable and pass it down to the Form component
...
const [text, setText] = useState('');
...
<Form
address={address}
setText={setText}
text={text}
buttonText={buttonText}
/>
...
In the Form component we set up the event listener that is triggered whenever the user types something into the input field
export default function Form({ setText, text }) {
return (
<form className="lg:w-1/2 flex flex-col w-full h-full bg-white overflow-hidden mb-[50px] shadow-md rounded-[8px] py-5 px-10 box-border">
...
<div className="w-4/5 lg:w-[90%]">
<input type="text" id="post" className="font-openSans w-full p-3 text-md border rounded-[22px] focus:outline-none focus:shadow-outline" placeholder="Share Your Thoughts and Ideas!"
value={text}
onChange={e=>setText(e.target.value)}
/>
</div>
...
</form>
)
}
Navigate back to index.js
and let’s implement publishPost function
const [publishLoading, setPublishLoading] = useState(false);
We declare publishLoading state variable to track the loading state while executing the function
const publishPost = async (text) => {
if(text.length > 0 && contract){
try {
setPublishLoading(true);
const data = await contract.call('addPost', text);
if(data.receipt.status === 1){
getPosts();
setText('');
}
return data;
} catch (err) {
console.error("contract call failure", err);
} finally {
setPublishLoading(false);
}
}
}
Here we are calling the addPost function and passing the input text to it, after checking if the text input is not empty and the contract exists.
If the transaction was successful, we call the getPosts function, and clear out the input field.
Next, we pass the function down to the Form component
...
<Form
...
publishPost={publishPost}
...
/>
...
Then we update the Form component to trigger the publishPost on submit
export default function Form({ ... text, publishPost }) {
return (
<form className="lg:w-1/2 flex flex-col w-full h-full bg-white overflow-hidden mb-[50px] shadow-md rounded-[8px] py-5 px-10 box-border" onSubmit={() => publishPost(text)}>
...
</form>
)
}
Now we implement the deletePost function
onst deletePost = async (id) => {
try {
const data = await contract.call('deletePost', id);
if(data.receipt.status === 1){
getPosts();
}
return data;
} catch (err) {
console.error("contract call failure", err);
}
Similar to the functions we implemented earlier, we call the deletePost function and passing the post id to it, and pass it down to Post component
{posts.map(post =>
<Post
...
deletePost={deletePost}
/>)
}
Update Post.js
export default function Post({ ... id, owner, address, deletePost }) {
return (
<div className="lg:w-1/2 flex flex-col w-full h-full bg-white overflow-hidden mb-6 shadow-md rounded-[8px] pt-2 pb-5 px-6 box-border">
{ owner === address &&
<button className="w-[10px] cursor-pointer" onClick={() => deletePost(id)}>
<svg className="fill-current w-3 h-3" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="122.878px" height="122.88px" viewBox="0 0 122.878 122.88" enableBackground="new 0 0 122.878 122.88" xml-space="preserve">
<g>
<path d="M1.426,8.313c-1.901-1.901-1.901-4.984,0-6.886c1.901-1.902,4.984-1.902,6.886,0l53.127,53.127l53.127-53.127 c1.901-1.902,4.984-1.902,6.887,0c1.901,1.901,1.901,4.985,0,6.886L68.324,61.439l53.128,53.128c1.901,1.901,1.901,4.984,0,6.886 c-1.902,1.902-4.985,1.902-6.887,0L61.438,68.326L8.312,121.453c-1.901,1.902-4.984,1.902-6.886,0 c-1.901-1.901-1.901-4.984,0-6.886l53.127-53.128L1.426,8.313L1.426,8.313z"></path>
</g>
</svg>
</button>
}
</div>
)
}
Here we are checking if the owner of the post has the same address that is connected to the app, and if so showing the close button.
One final thing that is left, let's add some conditional styles and text to the submit button. We need to disable it if the app is not connected to the wallet or not connected to the Mumbai network, or if the publishLoading state variable is true.
index.js
...
const buttonText = !address ? "Connect Wallet" : chainId !== ChainId.Mumbai ? "Connect to the Mumbai TestNet" : publishLoading ? "Loading..." : "Publish";
const buttonDisabled = !address || chainId !== ChainId.Mumbai || publishLoading;
...
Path both variables to Form component
<Form
...
buttonDisabled={buttonDisabled}
buttonText={buttonText}
/>
Update Form.js
export default function Form({ ... buttonText, buttonDisabled }) {
return (
<form className="lg:w-1/2 flex flex-col w-full h-full bg-white overflow-hidden mb-[50px] shadow-md rounded-[8px] py-5 px-10 box-border" onSubmit={() => publishPost(text)}>
...
<div className="w-full justify-center items-center">
<button className="w-full bg-[#1974E7] px-2 py-2 rounded-[6px] flex justify-center cursor-pointer disabled:opacity-50 disabled:cursor-default" type='submit' disabled={buttonDisabled} onClick={() => publishPost(text)}>
<h2 className='font-openSans text-white transition'>{buttonText}</h2>
</button>
</div>
</form>
)
}
Notice how we are using the Tailwind's disabled:
modifier to customize the disabled style.
And that's a wrap!
Full code can be found in the github repo here.
Conclusion:
Whew, you made it to the end of the tutorial! You now have a solid understanding of how to build a full-stack dApp using some of the coolest and trendiest technologies out there. To take your learning further, you can add the update functionality to the post content, loading state for getPosts, and you can also use packages like Draft.js to add a customizable text editor instead of the simple input that we implemented. Additionally, you can implement user profile routing.
Thanks for following along. Have a good one!
Top comments (0)