Building a Mini dApp on Ethereum: From Smart Contract to Frontend


Building decentralized applications (dApps) on Ethereum has become increasingly accessible with modern tooling and frameworks. In this guide, we'll walk through creating a simple voting dApp that demonstrates core blockchain development concepts, smart contract deployment, and frontend integration.
What We're Building
Our mini dApp will be a simple voting system where users can:
Create voting proposals
Cast votes on proposals
View voting results in real-time
Connect their MetaMask wallet
Tech Stack Overview
Smart Contract Layer:
Solidity for smart contract development
Hardhat for development environment
OpenZeppelin for security standards
Frontend Layer:
React.js for the user interface
Ethers.js for blockchain interaction
MetaMask for wallet connectivity
Deployment & Testing:
Sepolia testnet for deployment
Hardhat local network for testing
Prerequisites
Before we start, ensure you have:
Node.js (v16 or higher)
MetaMask browser extension
Basic understanding of JavaScript and React
Some test ETH from Sepolia faucet
Step 1: Project Setup
First, let's initialize our project structure:
mkdir voting-dapp
cd voting-dapp
npm init -y
# Install Hardhat and dependencies
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
# Initialize Hardhat project
npx hardhat init
Choose "Create a TypeScript project" when prompted.
Step 2: Writing the Smart Contract
Create contracts/Voting.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Voting is Ownable, ReentrancyGuard {
struct Proposal {
uint256 id;
string description;
uint256 voteCount;
bool exists;
mapping(address => bool) hasVoted;
}
uint256 public proposalCount;
mapping(uint256 => Proposal) public proposals;
event ProposalCreated(uint256 indexed proposalId, string description);
event VoteCast(uint256 indexed proposalId, address indexed voter);
constructor() {}
function createProposal(string memory _description) public onlyOwner {
proposalCount++;
Proposal storage newProposal = proposals[proposalCount];
newProposal.id = proposalCount;
newProposal.description = _description;
newProposal.voteCount = 0;
newProposal.exists = true;
emit ProposalCreated(proposalCount, _description);
}
function vote(uint256 _proposalId) public nonReentrant {
require(proposals[_proposalId].exists, "Proposal does not exist");
require(!proposals[_proposalId].hasVoted[msg.sender], "Already voted");
proposals[_proposalId].hasVoted[msg.sender] = true;
proposals[_proposalId].voteCount++;
emit VoteCast(_proposalId, msg.sender);
}
function getProposal(uint256 _proposalId) public view returns (
uint256 id,
string memory description,
uint256 voteCount
) {
require(proposals[_proposalId].exists, "Proposal does not exist");
Proposal storage proposal = proposals[_proposalId];
return (proposal.id, proposal.description, proposal.voteCount);
}
function hasVoted(uint256 _proposalId, address _voter) public view returns (bool) {
return proposals[_proposalId].hasVoted[_voter];
}
}
Step 3: Testing the Smart Contract
Create test/Voting.test.ts
:
import { expect } from "chai";
import { ethers } from "hardhat";
import { Voting } from "../typechain-types";
describe("Voting Contract", function () {
let voting: Voting;
let owner: any;
let voter1: any;
let voter2: any;
beforeEach(async function () {
[owner, voter1, voter2] = await ethers.getSigners();
const VotingFactory = await ethers.getContractFactory("Voting");
voting = await VotingFactory.deploy();
await voting.deployed();
});
it("Should create a proposal", async function () {
await voting.createProposal("Should we implement feature X?");
const proposal = await voting.getProposal(1);
expect(proposal.description).to.equal("Should we implement feature X?");
expect(proposal.voteCount).to.equal(0);
});
it("Should allow voting on proposals", async function () {
await voting.createProposal("Test proposal");
await voting.connect(voter1).vote(1);
const proposal = await voting.getProposal(1);
expect(proposal.voteCount).to.equal(1);
});
it("Should prevent double voting", async function () {
await voting.createProposal("Test proposal");
await voting.connect(voter1).vote(1);
await expect(voting.connect(voter1).vote(1))
.to.be.revertedWith("Already voted");
});
});
Run tests with:
npx hardhat test
Step 4: Deployment Configuration
Update hardhat.config.ts
:
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";
dotenv.config();
const config: HardhatUserConfig = {
solidity: "0.8.19",
networks: {
sepolia: {
url: process.env.SEPOLIA_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};
export default config;
Create .env
file:
SEPOLIA_URL=https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID
PRIVATE_KEY=your_wallet_private_key
ETHERSCAN_API_KEY=your_etherscan_api_key
Step 5: Deployment Script
Create scripts/deploy.ts
:
import { ethers } from "hardhat";
async function main() {
const VotingFactory = await ethers.getContractFactory("Voting");
console.log("Deploying Voting contract...");
const voting = await VotingFactory.deploy();
await voting.deployed();
console.log(`Voting contract deployed to: ${voting.address}`);
// Create a sample proposal
const tx = await voting.createProposal("Should we add more features to this dApp?");
await tx.wait();
console.log("Sample proposal created!");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Deploy to Sepolia:
npx hardhat run scripts/deploy.ts --network sepolia
Step 6: Frontend Development
Initialize React app:
npx create-react-app frontend --template typescript
cd frontend
npm install ethers @types/node
Create src/hooks/useContract.ts
:
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import VotingABI from '../contracts/Voting.json';
const CONTRACT_ADDRESS = 'YOUR_DEPLOYED_CONTRACT_ADDRESS';
export const useContract = () => {
const [contract, setContract] = useState<ethers.Contract | null>(null);
const [account, setAccount] = useState<string>('');
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
useEffect(() => {
const initContract = async () => {
if (window.ethereum) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const contract = new ethers.Contract(CONTRACT_ADDRESS, VotingABI.abi, signer);
setProvider(provider);
setContract(contract);
}
};
initContract();
}, []);
const connectWallet = async () => {
if (window.ethereum) {
try {
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
setAccount(accounts[0]);
} catch (error) {
console.error('Error connecting wallet:', error);
}
}
};
return { contract, account, provider, connectWallet };
};
Create main component src/components/VotingApp.tsx
:
import React, { useState, useEffect } from 'react';
import { useContract } from '../hooks/useContract';
interface Proposal {
id: number;
description: string;
voteCount: number;
}
const VotingApp: React.FC = () => {
const { contract, account, connectWallet } = useContract();
const [proposals, setProposals] = useState<Proposal[]>([]);
const [newProposal, setNewProposal] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
loadProposals();
}, [contract]);
const loadProposals = async () => {
if (!contract) return;
try {
const proposalCount = await contract.proposalCount();
const loadedProposals: Proposal[] = [];
for (let i = 1; i <= proposalCount.toNumber(); i++) {
const proposal = await contract.getProposal(i);
loadedProposals.push({
id: proposal.id.toNumber(),
description: proposal.description,
voteCount: proposal.voteCount.toNumber(),
});
}
setProposals(loadedProposals);
} catch (error) {
console.error('Error loading proposals:', error);
}
};
const createProposal = async () => {
if (!contract || !newProposal.trim()) return;
setLoading(true);
try {
const tx = await contract.createProposal(newProposal);
await tx.wait();
setNewProposal('');
loadProposals();
} catch (error) {
console.error('Error creating proposal:', error);
}
setLoading(false);
};
const vote = async (proposalId: number) => {
if (!contract) return;
setLoading(true);
try {
const tx = await contract.vote(proposalId);
await tx.wait();
loadProposals();
} catch (error) {
console.error('Error voting:', error);
}
setLoading(false);
};
return (
<div className="min-h-screen bg-gray-100 py-8">
<div className="max-w-4xl mx-auto px-4">
<h1 className="text-3xl font-bold text-center mb-8">Decentralized Voting App</h1>
{!account ? (
<div className="text-center">
<button
onClick={connectWallet}
className="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600"
>
Connect Wallet
</button>
</div>
) : (
<div className="space-y-8">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Connected: {account.slice(0, 6)}...{account.slice(-4)}</h2>
<div className="flex gap-4">
<input
type="text"
value={newProposal}
onChange={(e) => setNewProposal(e.target.value)}
placeholder="Enter proposal description"
className="flex-1 border border-gray-300 px-4 py-2 rounded-lg"
/>
<button
onClick={createProposal}
disabled={loading}
className="bg-green-500 text-white px-6 py-2 rounded-lg hover:bg-green-600 disabled:opacity-50"
>
Create Proposal
</button>
</div>
</div>
<div className="space-y-4">
<h2 className="text-xl font-semibold">Active Proposals</h2>
{proposals.map((proposal) => (
<div key={proposal.id} className="bg-white p-6 rounded-lg shadow">
<h3 className="font-semibold mb-2">Proposal #{proposal.id}</h3>
<p className="text-gray-700 mb-4">{proposal.description}</p>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Votes: {proposal.voteCount}</span>
<button
onClick={() => vote(proposal.id)}
disabled={loading}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
Vote
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default VotingApp;
Step 7: Integration with Different Protocols
Layer 2 Solutions
Polygon Integration:
// In hardhat.config.ts, add Polygon network
polygon: {
url: "https://polygon-rpc.com/",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
Arbitrum Integration:
arbitrum: {
url: "https://arb1.arbitrum.io/rpc",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
IPFS Integration for Metadata
// Install IPFS client
npm install ipfs-http-client
// In your React component
import { create } from 'ipfs-http-client';
const ipfs = create({ url: 'https://ipfs.infura.io:5001/api/v0' });
const uploadToIPFS = async (data: any) => {
try {
const result = await ipfs.add(JSON.stringify(data));
return result.path;
} catch (error) {
console.error('IPFS upload error:', error);
}
};
Step 8: Security Best Practices
Access Control: Use OpenZeppelin's
Ownable
andAccessControl
Reentrancy Protection: Implement
ReentrancyGuard
Integer Overflow: Solidity 0.8+ has built-in protection
Gas Optimization: Use
view
andpure
functions where possibleEvent Logging: Emit events for important state changes
Step 9: Testing and Deployment
Local Testing
# Start local Hardhat network
npx hardhat node
# Deploy to local network
npx hardhat run scripts/deploy.ts --network localhost
Testnet Deployment
# Deploy to Sepolia
npx hardhat run scripts/deploy.ts --network sepolia
# Verify contract
npx hardhat verify --network sepolia CONTRACT_ADDRESS
Conclusion
We've successfully built a complete voting dApp that demonstrates:
Smart contract development with Solidity
Security best practices with OpenZeppelin
Frontend integration with React and Ethers.js
Multi-network deployment capabilities
Testing and verification workflows
This foundation can be extended with features like:
Time-based voting periods
Token-weighted voting
Proposal categories
Advanced governance mechanisms
Integration with ENS for human-readable addresses
The modular architecture makes it easy to adapt this dApp for different use cases while maintaining security and scalability principles essential for production blockchain applications.
Next Steps
Enhanced UI/UX: Add loading states, error handling, and responsive design
Advanced Features: Implement delegation, quadratic voting, or liquid democracy
Analytics: Add proposal analytics and voting history
Mobile Support: Create React Native version or PWA
Integration: Connect with existing governance tokens or DAOs
Start building, experiment with different protocols, and remember that the blockchain development ecosystem is rapidly evolving. Stay updated with the latest tools. Keep building ❤️
Subscribe to my newsletter
Read articles from Isha Parekh directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Isha Parekh
Isha Parekh
Building in public