Editor’s note: This article was updated on 11 April 2022 to align with the release of thirdweb v2.
DAO stands for Decentralized Autonomous Organization. As it says in the name, a DAO is an organization without a single leader; instead, rules are encoded in the blockchain. Because of this, a DAO is completely transparent and everyone who participates has a stake. Large decisions are made via voting amongst those who own non-fungible tokens (NFTs) from the DAO, which grant membership.
Today, we are going to build our very own DAO using Next.js, thirdweb, MetaMask, and Alchemy. It will allow users to mint your DAO’s NFT, receive cryptocurrency via airdrops, and participate in the DAO’s polls. This tutorial will be written with just JavaScript, so you don’t need to know any Solidity.
To understand and follow along with this tutorial, you should have the following:
We will begin by setting up a Next.js app with the following command:
npx create-next-app my-dao
Next, head to Alchemy, sign in, click on Create App, and provide the required details. Make sure to use the same chain as the one you used in thirdweb – in our case, it is the Ethereum chain and the Rinkeby network.
After the app is created, copy the HTTP API key.
In order to mint NFTs and perform certain scripts, we are going to need the wallet’s private key.
To access it, open the MetaMask browser extension and click on Account Details. You should see your private key here; export it and copy it somewhere safe.
.env
variablesLet’s add these variables in a .env
file so we can access them later:
PRIVATE_KEY=<wallet_private_key> ALCHEMY_API_URL=<alchemy_http_key> WALLET_ADDRESS=<public_wallet_address>
Because we don’t want to push these to GitHub, be sure to add them in gitignore
In DApps, MetaMask is the most popular wallet used, so we will add MetaMask sign in with thirdweb.
We are going to need two packages from install:
npm i @thirdweb-dev/react @thirdweb-dev/sdk ethers # npm yarn add @thirdweb-dev/react @thirdweb-dev/sdk ethers # yarn
We need to wrap our whole app in a thirdweb provider in order to access the login details and other information required for the components:
import "../styles/globals.css"; import {ThirdwebProvider } from "@thirdweb-dev/react"; function MyApp({ Component, pageProps }) { return ( <ThirdwebProvider desiredChainId={activeChainId}> <Component {...pageProps} /> </ThirdwebProvider> ); } export default MyApp;
For authentication purposes, we also have to specify the type of authentication and the supported chain IDs. We are using MetaMask and the Rinkeby chain, so add the following as well:
import { ChainId, ThirdwebProvider } from "@thirdweb-dev/react"; const activeChainId = ChainId.Rinkeby;
Finally, pass these as props in the provider like so:
<ThirdwebProvider desiredChainId={activeChainId}> <Component {...pageProps} /> </ThirdwebProvider>
Create a new folder called components
in the root of the project and add a Login.js
file to it:
import { useMetamask } from "@thirdweb-dev/react"; const Login = () => { const connectWithMetamask = useMetamask(); return ( <div> <button onClick={connectWithMetamask}>Sign in using MetaMask</button> </div> ); }; export default Login;
Thankfully, thirdweb provides a connectWallet
function which we can use to add authentication!
Inside index.js
, render the login screen if there is no address (if the user is not signed in):
const address = useAddress(); if (!address) { return ; }
This will allow our users to sign in, but afterwards it just shows a blank screen. So, in the other return block, let’s show the user her address:
export default function Home() { const { address } = useWeb3(); if (!address) { return <Login />; } return ( <div className={styles.container}> <h2>You are signed in as {address}</h2> </div> ); }
The login works but it doesn’t look good right now. So, create a new file Login.module.css
in the styles
folder and add the following:
.container { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; background-color: #7449bb; } .button { color: #7449bb; background-color: white; border: none; border-radius: 5px; padding: 10px; font-size: 16px; margin: 4px 2px; cursor: pointer; font-weight: 500; }
Next, add the following classes to Login.js
:
<div className={styles.container}> <button className={styles.button} onClick={() => connectWallet("injected")}> Sign in using MetaMask </button> </div>
And finally, import the styles:
import styles from "../styles/Login.module.css";
This will give us a simple, but good-looking login screen.
Now we need to initialize the thirdweb SDK for the various scripts we are going to run. Start by creating a new folder called scripts
with an initialize-sdk.js
file inside of it.
Add the following code to the file:
import { ThirdwebSDK } from "@thirdweb-dev/sdk"; import ethers from "ethers"; import dotenv from "dotenv"; dotenv.config(); if (!process.env.PRIVATE_KEY || process.env.PRIVATE_KEY === "") { console.log("🛑 Private key not found."); } if (!process.env.ALCHEMY_API_URL || process.env.ALCHEMY_API_URL === "") { console.log("🛑 Alchemy API URL not found."); } if (!process.env.WALLET_ADDRESS || process.env.WALLET_ADDRESS === "") { console.log("🛑 Wallet Address not found."); } const sdk = new ThirdwebSDK( new ethers.Wallet( process.env.PRIVATE_KEY, ethers.getDefaultProvider(process.env.ALCHEMY_API_URL) ) ); (async () => { try { const address = await sdk.getSigner().getAddress(); console.log("SDK initialized by address:", address); } catch (err) { console.error("Failed to get the address", err); process.exit(1); } })(); export default sdk;
This will initialize the thirdweb SDK, and as you can see, we need to install some packages:
npm i dotenv # npm yarn add dotenv # yarn
We are using modular imports here, so create a new package.json
file inside the scripts
folder and simply add the following:
{ "name": "scripts", "type": "module" }
Finally, run the script:
node scripts/initialize-sdk.js
The script may take some time to run, but after some time you will get your app address.
We are going to need this in the next steps, so store it somewhere safe.
For this step, we are going to need some test ETH, so go to a faucet like this and get some.
Create a new file called deploy-drop.js
inside the scripts
folder. In here, add the following script:
import { ethers } from "ethers"; import sdk from "./initialize-sdk.js"; (async () => { try { const editionDropAddress = await sdk.deployer.deployEditionDrop({ name: "LogRocket DAO", // Name of NFT Collection for DAO description: "A DAO for all the LogRocket readers.", // Description image: "image_Address", // PFP for NFT collection primary_sale_recipient: ethers.constants.AddressZero, }); const editionDrop = sdk.getEditionDrop(editionDropAddress); const metadata = await editionDrop.metadata.get(); console.log( "✅ Successfully deployed editionDrop contract, address:", editionDropAddress ); console.log("✅ editionDrop metadata:", metadata); } catch (error) { console.log("failed to deploy editionDrop contract", error); } })();
You will need to update a few things here:
assets
, and adding the image for your NFT thereAfter you have updated the details, run the following script:
node scripts/deploy-drop.js
Wait for the script to run, and you should get an address and the metadata.
This will create a new edition drop contract for us! You can even check out the transaction on Rinkeby Etherscan.
Let’s configure our NFT now! Create a new config-nft.js
file inside the scripts
folder and add the following:
import sdk from "./initialize-sdk.js"; const editionDrop = sdk.getEditionDrop("EDITION_DROP_ADDDRESS"); (async () => { try { await editionDrop.createBatch([ { name: "LogRocket DAO", // Name of NFT Collection for DAO description: "A DAO for all the LogRocket readers.", // Description image: "image_address", // Image for NFT }, ]); console.log("✅ Successfully created a new NFT in the drop!"); } catch (error) { console.error("failed to create the new NFT", error); } })();
You need to update the bundle drop address and the details in the object inside createBatch
. These details are going to be used for the NFT!
Once, you have updated all of them, run the following script:
node scripts/config-nft.js
It should give you an output like this.
If you see the module in the thirdweb dashboard, you will see that an NFT has been created! 🥳
Finally, let’s add a claim condition to our NFT.
Setting a claim condition will allow us to set a limit for the NFTs and allow a specific max limit per transaction. We will set a claim condition from the dashboard itself, so click on the Settings button and create a new claim phase.
After you are done updating, click Update claim phase and confirm the small transaction.
Before creating a mint button that allows the users to mint NFTs, let’s check if the user has an NFT already. We don’t want the users to mint multiple NFTs!
Start by adding two new variables, sdk
and bundleDropModule
, like this before our functional component:
const editionDrop = useEditionDrop( "0x2f66A5A2BCB272FFC9EB873E3482A539BEB6f02a" );
You will also need to import useEditionDrop
:
import { useAddress, useEditionDrop } from "@thirdweb-dev/react";
Now, let’s create a state for hasClaimedNFT
:
const [hasClaimedNFT, setHasClaimedNFT] = useState(false);
We also need to create a useEffect
Hook to check if the user has the NFT:
useEffect(() => { if (!address) { return; } const checkBalance = async () => { try { const balance = await editionDrop.balanceOf(address, 0); if (balance.gt(0)) { setHasClaimedNFT(true); console.log("🎉 You have an NFT!"); } else { setHasClaimedNFT(false); console.log("🤷♂️ You don't have an NFT."); } } catch (error) { setHasClaimedNFT(false); console.error("Failed to get nft balance", error); } }; checkBalance(); }, [address, editionDrop]);
Firstly, it will check if the user is signed in. If the user is not signed in, it will return nothing. Then, this checks if the user has the NFT with the token ID 0
in the drop contract that we imported at the top.
If you, open the console in the website, it should show that you don’t have an NFT.
Let’s create the button to mint NFTs! Create a new function called mintNft
like so:
const mintNft = async () => { setIsClaiming(true); try { await bundleDropModule.claim("0", 1); setHasClaimedNFT(true); console.log("🌊 Successfully Minted the NFT!"); } catch (error) { console.error("failed to claim", error); } finally { setIsClaiming(false); } };
We will call this function when a button is clicked to mint the NFT to the user’s wallet. But first, let’s add the isClaiming
state:
const [isClaiming, setIsClaiming] = useState(false);
Let’s create the button now! Inside the final return block add the following:
<div> <h1>Mint your free LogRocket DAO Membership NFT 💳</h1> <button disabled={isClaiming} onClick={() => mintNft()}> {isClaiming ? "Minting..." : "Mint your nft (FREE)"} </button> </div>
Now, after we sign in, it should show us a screen like this.
If you try the Mint your nft (FREE) button, it should pop up your MetaMask screen to complete the transaction. In the console, should should see the following.
Finally, just above the final return block, add this check to see if the user has claimed the NFT already:
if (hasClaimedNFT) { return ( <div> <h1>You have the DAO Membership NFT!</h1> </div> ); }
We have completed building the minting NFT functionality, but it looks ugly, so let’s add some basic stylings. Inside Home.module.css
add the following:
.container { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; background-color: #7449bb; } .container > h1 { font-size: 3rem; color: #fff; font-weight: bold; } .button { color: #7449bb; background-color: white; border: none; border-radius: 5px; padding: 10px; font-size: 16px; margin: 4px 2px; cursor: pointer; font-weight: 500; }
We also need to add the classNames
:
if (hasClaimedNFT) { return ( <div className={styles.container}> <h1>You have the DAO Membership NFT!</h1> </div> ); } return ( <div className={styles.container}> <h1>Mint your free LogRocket DAO Membership NFT 💳</h1> <button className={styles.button} disabled={isClaiming} onClick={() => mintNft()} > {isClaiming ? "Minting..." : "Mint your NFT (FREE)"} </button> </div> ); };
This gives us a better mint screen.
Create a new file called deploy-token.js
in the scripts
folder. Add the following to it:
import { AddressZero } from "@ethersproject/constants"; import sdk from "./initialize-sdk.js"; (async () => { try { const tokenAddress = await sdk.deployer.deployToken({ name: "LogRocket Token", // name of the token symbol: "LR", // symbol primary_sale_recipient: AddressZero, // 0x0000000000000000000000000000000000000000 }); console.log( "✅ Successfully deployed token module, address:", tokenAddress ); } catch (error) { console.error("failed to deploy token module", error); } })();
This script will create a new token module with a name and symbol. You will need to manually update the app address, token name, and symbol yourself.
After updating, run the script.
You can check this token by the address on Rinkeby Etherscan, and also add it on your MetaMask wallet by clicking Import tokens.
After importing, you should see the token under your assets.
It is currently zero, so let’s mint some tokens!
Create a new file called mint-token.js
inside the scripts
folder and add the following:
import { ethers } from "ethers"; import sdk from "./initialize-sdk.js"; const tokenModule = sdk.getTokenModule( "TOKEN_MODULE_ADDRESS" ); (async () => { try { const amount = 1_000_000; const amountWith18Decimals = ethers.utils.parseUnits(amount.toString(), 18); await tokenModule.mint(amountWith18Decimals); const totalSupply = await tokenModule.totalSupply(); console.log( "✅ There now is", ethers.utils.formatUnits(totalSupply, 18), "$LR in circulation" ); } catch (error) { console.error("Failed to mint tokens", error); } })();
Update the token module address with the address you got in the last script, and you can update the amount you want to mint.
After you are ready to mint, run the script:
node scripts/mint-token.js
You should now see the amount of tokens you minted on your MetaMask wallet! 🎉
We might want to airdrop the tokens to our NFT holders, so let’s build a script for that. Create a new airdrop.js
file inside scripts
and add the following:
import sdk from "./initialize-sdk.js"; const editionDrop = sdk.getEditionDrop( "EDITION_ADDRESS" ); const token = sdk.getToken("TOKEN_ADDRESS"); (async () => { try { const walletAddresses = await editionDrop.history.getAllClaimerAddresses(0); if (walletAddresses.length === 0) { console.log( "No NFTs have been claimed yet, ask yourfriends to claim some free NFTs!" ); process.exit(0); } const airdropTargets = walletAddresses.map((address) => { const randomAmount = Math.floor( Math.random() * (10000 - 1000 + 1) + 1000 ); console.log("✅ Going to airdrop", randomAmount, "tokens to", address); const airdropTarget = { toAddress: address, amount: randomAmount, }; return airdropTarget; }); console.log("🌈 Starting airdrop..."); await token.transferBatch(airdropTargets); console.log( "✅ Successfully airdropped tokens to all the holders of the NFT!" ); } catch (err) { console.error("Failed to airdrop tokens", err); } })();
After you run the script you should get something like this.
Currently, only you have minted an NFT, so it won’t send the token to someone else. But this can be used to send it to other NFT holders later on.
Create a new deploy-vote.js
file in the scripts
folder and add the following:
import sdk from "./initialize-sdk.js"; (async () => { try { const voteContractAddress = await sdk.deployer.deployVote({ name: "LR Dao's Proposals", voting_token_address: "TOKEN_ADDRESS", voting_delay_in_blocks: 0, voting_period_in_blocks: 6570, voting_quorum_fraction: 0, proposal_token_threshold: 0, }); console.log( "✅ Successfully deployed vote contract, address:", voteContractAddress ); } catch (err) { console.error("Failed to deploy vote contract", err); } })();
Update the app address, the name, and the voting token address, then run the script:
node scripts/deploy-vote.js
We also need to set up a vote module, so create a new script called setup-vote.js
and add the following:
import sdk from "./initialize-sdk.js"; const vote = sdk.getVote("VOTE_ADDRESS"); const token = sdk.getToken("TOKEN_ADDRESS"); (async () => { try { await token.roles.grant("minter", vote.getAddress()); console.log( "Successfully gave vote contract permissions to act on token contract" ); } catch (error) { console.error( "failed to grant vote contract permissions on token contract", error ); process.exit(1); } try { const ownedTokenBalance = await token.balanceOf(process.env.WALLET_ADDRESS); const ownedAmount = ownedTokenBalance.displayValue; const percent90 = (Number(ownedAmount) / 100) * 90; await token.transfer(vote.getAddress(), percent90); console.log( "✅ Successfully transferred " + percent90 + " tokens to vote contract" ); } catch (err) { console.error("failed to transfer tokens to vote contract", err); } })();
You will need to run this script to finish it up:
node scripts/setup-vote.js
Now that we have our vote module ready, let’s create some proposals!
Create a new file called vote-proposals.js
inside the scripts
folder and add the following:
import sdk from "./initialize-sdk.js"; import { ethers } from "ethers"; const vote = sdk.getVote("0x31c5840b31A1F97745bDCbB1E46954b686828E0F"); const token = sdk.getToken("0x6eefd78C9C73505AA71A13FeE31D9718775c9086"); (async () => { try { const amount = 420_000; const description = "Should the DAO mint an additional " + amount + " tokens into the treasury?"; const executions = [ { toAddress: token.getAddress(), nativeTokenValue: 0, transactionData: token.encoder.encode("mintTo", [ vote.getAddress(), ethers.utils.parseUnits(amount.toString(), 18), ]), }, ]; await vote.propose(description, executions); console.log("✅ Successfully created proposal to mint tokens"); } catch (error) { console.error("failed to create first proposal", error); process.exit(1); } })();
You need to update the module addresses, and if you want to update the message of the proposal, you can update that as well.
Finally, run the script. It should give you something like this.
If you now check the thirdweb dashboard, the proposal has been created. 🎉
First, import the token and vote module:
const token = useToken("TOKEN_ADDRESS"); const vote = useVote("VOTE_ADDRESS");
We are going to need three useState
s, like so:
const [proposals, setProposals] = useState([]); const [isVoting, setIsVoting] = useState(false); const [hasVoted, setHasVoted] = useState(false);
We need to get the proposals to display them on the screen, so create this useEffect
:
useEffect(() => { if (!hasClaimedNFT) { return; } const getAllProposals = async () => { try { const proposals = await vote.getAll(); setProposals(proposals); console.log("📋 Proposals:", proposals); } catch (error) { console.log("failed to get proposals", error); } }; getAllProposals(); }, [hasClaimedNFT, vote]);
Then, create a new handleFormSubmit
function:
const handleFormSubmit = async (e) => { e.preventDefault(); e.stopPropagation(); setIsVoting(true); const votes = proposals.map((proposal) => { const voteResult = { proposalId: proposal.proposalId, vote: 2, }; proposal.votes.forEach((vote) => { const elem = document.getElementById( proposal.proposalId + "-" + vote.type ); if (elem.checked) { voteResult.vote = vote.type; return; } }); return voteResult; }); try { const delegation = await token.getDelegationOf(address); if (delegation === AddressZero) { await token.delegateTo(address); } try { await Promise.all( votes.map(async ({ proposalId, vote: _vote }) => { const proposal = await vote.get(proposalId); if (proposal.state === 1) { return vote.vote(proposalId, _vote); } return; }) ); try { await Promise.all( votes.map(async ({ proposalId }) => { const proposal = await vote.get(proposalId); if (proposal.state === 4) { return vote.execute(proposalId); } }) ); setHasVoted(true); console.log("successfully voted"); } catch (err) { console.error("failed to execute votes", err); } } catch (err) { console.error("failed to vote", err); } } catch (err) { console.error("failed to delegate tokens"); } finally { setIsVoting(false); } };
This function is going to collect the vote.
Replace the if (hasClaimedNFT)
block with this:
if (hasClaimedNFT) { return ( <div className={styles.container}> <h2>Active Proposals</h2> <form onSubmit={handleFormSubmit}> {proposals.map((proposal) => ( <Proposal key={proposal.proposalId} votes={proposal.votes} description={proposal.description} proposalId={proposal.proposalId} /> ))} <button onClick={handleFormSubmit} type="submit" className={styles.button} > {isVoting ? "Voting..." "Submit Votes"} </button> </form> </div> ); }
We are creating a separate component for the proposal to keep things clean. So, create a new file called Proposal.js
in the components
folder and add the following:
import styles from "../styles/Proposal.module.css"; const Proposal = ({ description, votes, proposalId }) => { return ( <div className={styles.proposal}> <h5 className={styles.description}>{description}</h5> <div className={styles.options}> {votes.map((vote) => ( <div key={vote.type}> <input type="radio" id={proposalId + "-" + vote.type} name={proposalId} value={vote.type} defaultChecked={vote.type === 2} /> <label htmlFor={proposalId + "-" + vote.type}>{vote.label}</label> </div> ))} </div> </div> ); }; export default Proposal;
I also added basic styling, so create a new Proposal.module.css
file in the styles
folder:
.proposal { display: flex; flex-direction: column; align-items: center; justify-content: center; background-color: #fafafa; border-radius: 10px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); padding: 20px; margin: 20px; } .options { display: flex; flex-direction: column; justify-content: space-between; align-items: flex-start; width: 100%; margin-top: 1rem; }
To center the items, I have added the following styles in Home.module.css
as well:
.container > form { display: flex; flex-direction: column; align-items: center; }
You will get to this screen where you can submit your votes. 🎉
Finally, let’s make a function to check if the person has already voted.
First, create a new useEffect
:
useEffect(() => { if (!hasClaimedNFT) { return; } if (!proposals.length) { return; } const checkIfUserHasVoted = async () => { try { const hasVoted = await vote.hasVoted(proposals[0].proposalId, address); setHasVoted(hasVoted); } catch (error) { console.error("Failed to check if wallet has voted", error); } }; checkIfUserHasVoted(); }, [hasClaimedNFT, proposals, address, vote]);
And replace the button with this:
<button onClick={handleFormSubmit} type="submit" disabled={isVoting || hasVoted} className={styles.button} > {isVoting ? "Voting..." : hasVoted ? "You Already Voted" : "Submit Votes"} </button>
After you have voted, it should show the message You Already Voted:
This was it for this tutorial, hope you liked it and can use it to make your own DAO! You can always update the DAO and add more features if you like.✌️
You can find the GitHub repo for this project here.
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]
2 Replies to "How to create a DAO with Next.js and thirdweb"
Sadly it seems like this tutorial is already outdated! Thirdweb moves fast…
There’s no longer a getApps function – that was in v1 of the 3rdweb sdk and has been removed from v2. Plus, you’re no longer able to create projects. Which means your tutorial fails when you get to the initilise-script stage; getApps() returns an empty array.
Thanks for the tip! You’re right, the tutorial did become outdated very quickly. We’ve updated the post to reflect the changes introduced with thirdweb v2.