On-chain voting is one of the clearest use cases for smart contracts: rules are public, results are verifiable by anyone, and nobody has to trust a single operator to count ballots honestly. It is also one of the easiest dApps to get subtly wrong. Eligibility, privacy, gas costs and wallet UX all pull in different directions, and the choices you make early are hard to change once a contract is deployed.
This guide walks through building a production-minded blockchain voting dApp with Solidity on the back end and Next.js on the front end. We will cover architecture, voter registration, ballot contract design, commit–reveal privacy, gas and Layer 2 choices, wallet connection with wagmi and viem, testing, deployment and auditing, finishing with the UX pitfalls that trip up real users. If you would rather start from a working codebase, the Vote Chain theme implements many of the patterns described here and is a useful reference while you read.
Architecture overview
A voting dApp has fewer moving parts than a DeFi protocol, but each one carries more weight because the output — an election result — must be trusted by people who may not trust each other. A typical architecture has four layers:
- Smart contracts that define elections, eligible voters, voting windows and tallying rules. This is the source of truth.
- A frontend (Next.js) that reads contract state, lets voters connect a wallet, and submits transactions.
- An indexing layer (optional but common) that listens to contract events and serves fast queries such as “all elections I can vote in” or historical results. This can be a subgraph, a custom indexer, or simple server-side reads with caching.
- Off-chain metadata storage for proposal descriptions, candidate bios and images. Store a content hash on-chain and the content itself on IPFS or similar, so the text cannot be quietly edited after voting starts.
The key design principle: anything that affects the outcome belongs on-chain, and anything that is merely presentational can live off-chain as long as its integrity is verifiable by hash. If your backend can change who is eligible or what a proposal says without leaving an on-chain trace, you have rebuilt a centralized voting system with extra steps.
Token-weighted vs. one-person-one-vote
Decide early which model you need. DAO governance usually weights votes by token balance, often using snapshots (for example, OpenZeppelin’s ERC20Votes checkpoints) so people cannot buy tokens, vote, and sell. Community polls, associations and student elections usually want one vote per verified person, which moves the hard problem to identity and registration. The contract below targets the second model, because it is the one most teams underestimate.
Voter registration and allowlists
Blockchains know addresses, not people. Every one-person-one-vote system therefore needs a mapping from real-world eligibility to addresses, and that mapping is where most trust assumptions hide. Common approaches:
- Admin-registered allowlist. An organizer calls
registerVoters(address[]). Simple and transparent, but writing thousands of addresses to storage is expensive, and the admin is a trusted party. - Merkle allowlist. The organizer publishes a Merkle root of eligible addresses. Each voter submits a proof when voting. Registration costs one storage write regardless of list size, and anyone can verify the list off-chain against the root.
- Token or NFT gating. Eligibility equals holding a non-transferable (soulbound) membership token. Good for ongoing communities that vote repeatedly.
- Signature-based vouchers. An off-chain registrar signs an EIP-712 message authorizing an address for a specific election. Flexible, but the registrar key becomes a critical secret.
For most projects, a Merkle allowlist hits the sweet spot: cheap, auditable, and easy to publish alongside the election metadata. Whatever you choose, publish the full eligibility list (or its commitment) before voting opens, so observers can challenge omissions or duplicates.
Designing the ballot smart contract
A good ballot contract is small, explicit about time, and emits events for everything a frontend or auditor might want to reconstruct. Below is a simplified example that supports multiple elections, a Merkle-based allowlist, and a commit–reveal flow. It uses OpenZeppelin’s MerkleProof and Ownable utilities.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract Ballot is Ownable {
struct Election {
bytes32 voterRoot; // Merkle root of eligible addresses
bytes32 metadataHash; // hash of off-chain proposal content
uint64 commitEnd; // commits accepted until this timestamp
uint64 revealEnd; // reveals accepted until this timestamp
uint8 optionCount;
}
uint256 public electionCount;
mapping(uint256 => Election) public elections;
mapping(uint256 => mapping(address => bytes32)) public commitments;
mapping(uint256 => mapping(address => bool)) public revealed;
mapping(uint256 => mapping(uint8 => uint256)) public tally;
event ElectionCreated(uint256 indexed id, bytes32 metadataHash, uint64 commitEnd, uint64 revealEnd);
event VoteCommitted(uint256 indexed id, address indexed voter);
event VoteRevealed(uint256 indexed id, address indexed voter, uint8 option);
error NotEligible();
error WrongPhase();
error AlreadyCommitted();
error BadReveal();
constructor() Ownable(msg.sender) {}
function createElection(
bytes32 voterRoot,
bytes32 metadataHash,
uint64 commitEnd,
uint64 revealEnd,
uint8 optionCount
) external onlyOwner returns (uint256 id) {
require(commitEnd > block.timestamp && revealEnd > commitEnd, "bad times");
require(optionCount > 1, "need options");
id = ++electionCount;
elections[id] = Election(voterRoot, metadataHash, commitEnd, revealEnd, optionCount);
emit ElectionCreated(id, metadataHash, commitEnd, revealEnd);
}
function commit(uint256 id, bytes32 commitment, bytes32[] calldata proof) external {
Election storage e = elections[id];
if (block.timestamp >= e.commitEnd) revert WrongPhase();
if (commitments[id][msg.sender] != bytes32(0)) revert AlreadyCommitted();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender))));
if (!MerkleProof.verify(proof, e.voterRoot, leaf)) revert NotEligible();
commitments[id][msg.sender] = commitment;
emit VoteCommitted(id, msg.sender);
}
function reveal(uint256 id, uint8 option, bytes32 salt) external {
Election storage e = elections[id];
if (block.timestamp < e.commitEnd || block.timestamp >= e.revealEnd) revert WrongPhase();
if (revealed[id][msg.sender] || option >= e.optionCount) revert BadReveal();
bytes32 expected = keccak256(abi.encode(id, msg.sender, option, salt));
if (commitments[id][msg.sender] != expected) revert BadReveal();
revealed[id][msg.sender] = true;
tally[id][option] += 1;
emit VoteRevealed(id, msg.sender, option);
}
}
A few deliberate choices are worth calling out:
- The commitment binds election id and voter address. Including
idandmsg.senderin the hash prevents someone from copying another voter’s commitment or replaying it in a different election. - Double-hashed Merkle leaves. Hashing the leaf twice follows OpenZeppelin’s recommendation to avoid second-preimage issues and matches the format produced by their JavaScript
merkle-treelibrary. - Custom errors are cheaper than revert strings and easy to decode in the frontend.
- Timestamps, not block numbers, because block times differ between chains and L2s.
- No admin override of results. The owner can create elections but cannot edit tallies. If you need emergency controls, make them visible (for example, a cancel function that emits an event) rather than silent.
Before shipping anything like this, run through a structured review such as our smart contract security checklist. Even a small contract benefits from a second pair of eyes.
Commit–reveal and privacy trade-offs
Everything on a public blockchain is visible. If voters submit plain votes, anyone can watch the running tally, which invites bandwagon effects, strategic voting and, worse, coercion: a third party can demand to see how you voted because it is right there on-chain.
Commit–reveal addresses the first problem. During the commit phase, voters submit only a hash of their choice plus a secret salt. Nobody can see the running tally. After the commit window closes, voters reveal their choice and salt, and the contract verifies the hash matches. The trade-offs are real, though:
| Approach | Hides running tally | Hides individual votes after the fact | UX cost |
|---|---|---|---|
| Plain on-chain votes | No | No | Lowest: one transaction |
| Commit–reveal | Yes, until reveal | No, votes become public on reveal | Two transactions; voters who forget to reveal are not counted |
| Encrypted votes with threshold decryption | Yes | Partially, depending on scheme | Requires trusted key holders or a committee |
| Zero-knowledge membership proofs | Yes | Yes, voter identity is unlinkable | Heavier client-side proving, more complex tooling and audits |
Commit–reveal does not make votes secret forever; it only delays disclosure. It also does not stop a voter from proving their vote to a briber, because they can simply share their salt. If your use case involves serious coercion risk, look at zero-knowledge approaches (for example, Semaphore-style group membership proofs or MACI-style designs that add a coordinator to resist collusion) and budget for specialist review.
crypto.getRandomValues, store it locally, and also offer the voter a downloadable backup. Lost salts are the number one reason commit–reveal votes go uncounted.Gas considerations and choosing a Layer 2
Each vote is a transaction, and each transaction costs gas. On Ethereum mainnet that can price out casual voters, especially with commit–reveal requiring two transactions. Practical ways to keep costs down:
- Deploy on a Layer 2. Rollups such as Arbitrum, Optimism, Base or zkSync-based chains inherit Ethereum security guarantees to varying degrees while charging a fraction of mainnet fees. Check each network’s current fee levels and decentralization status rather than relying on old comparisons.
- Minimize storage writes. Storage is the most expensive operation. Store a Merkle root instead of an allowlist, and pack small fields (like the
uint64timestamps above) into the same slot. - Use
calldatafor arrays such as Merkle proofs, and avoid unbounded loops over voters anywhere in the contract. - Consider sponsored transactions. With account abstraction (ERC-4337 paymasters) or a relayer using EIP-712 signed votes, the organizer can pay gas so voters do not need native tokens at all. This is often the single biggest UX win for non-crypto audiences.
- Hybrid off-chain voting. For low-stakes polls, signed off-chain votes aggregated and verified later (the model used by popular DAO signalling tools) can be good enough, with on-chain execution only for binding decisions.
Measure, do not guess. Both Hardhat (via a gas reporter plugin) and Foundry (forge test --gas-report) will show per-function gas usage so you can compare designs objectively.
Building the frontend with Next.js, wagmi and viem
The Next.js App Router pairs well with dApps: server components can render election metadata and cached results quickly, while client components handle wallet interaction. wagmi provides React hooks for accounts, reads and writes, and viem provides the typed, lightweight Ethereum client underneath.
Setting up providers
Wallet state is client-only, so wrap it in a client component and mount it in your root layout.
// app/providers.tsx
'use client';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { base, baseSepolia } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
const config = createConfig({
chains: [base, baseSepolia],
connectors: [
injected(),
walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! }),
],
transports: {
[base.id]: http(),
[baseSepolia.id]: http(),
},
ssr: true,
});
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}
Committing a vote
The commit button needs to compute the same hash the contract expects. viem’s encodeAbiParameters and keccak256 mirror Solidity’s abi.encode and keccak256 exactly.
'use client';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { encodeAbiParameters, keccak256, toHex, type Hex } from 'viem';
import { ballotAbi, BALLOT_ADDRESS } from '@/lib/contracts';
export function CommitVote({ electionId, option, proof }: {
electionId: bigint; option: number; proof: Hex[];
}) {
const { address } = useAccount();
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: confirming, isSuccess } = useWaitForTransactionReceipt({ hash });
function handleCommit() {
if (!address) return;
const salt = toHex(crypto.getRandomValues(new Uint8Array(32)));
const commitment = keccak256(
encodeAbiParameters(
[{ type: 'uint256' }, { type: 'address' }, { type: 'uint8' }, { type: 'bytes32' }],
[electionId, address, option, salt],
),
);
localStorage.setItem(`vote-${electionId}-${address}`, JSON.stringify({ option, salt }));
writeContract({
address: BALLOT_ADDRESS,
abi: ballotAbi,
functionName: 'commit',
args: [electionId, commitment, proof],
});
}
return (
<button onClick={handleCommit} disabled={!address || isPending || confirming}>
{isPending ? 'Confirm in wallet…' : confirming ? 'Submitting…' : isSuccess ? 'Vote committed' : 'Commit vote'}
{error && <span role="alert">{error.message}</span>}
</button>
);
}
In production you would wrap the localStorage access in error handling and offer the salt as a download, but the shape is the same. Define your ABI with as const (or generate it with a tool like wagmi CLI) so argument and return types are inferred end to end.
Reading results on the server
Results pages are ideal for server components. Create a viem public client on the server, read the tally, and use Next.js caching or revalidation so you are not hitting your RPC provider on every request. Wallet-specific data, such as “have I already voted?”, stays in client components with wagmi’s useReadContract. The Vote Chain template follows this split, which keeps the initial page load fast while still showing live, personalized state after the wallet connects.
Testing with Hardhat or Foundry
Voting contracts are time-dependent, so your tests must control the clock. Both major toolchains make this straightforward: Hardhat through its network helpers (time.increaseTo), Foundry through cheatcodes (vm.warp, vm.prank). Foundry tests are written in Solidity and run fast, which suits fuzzing; Hardhat tests are written in TypeScript and share code with your frontend. Many teams use both.
function test_RevealCountsVote() public {
uint256 id = _createElection();
bytes32 salt = keccak256("secret");
bytes32 c = keccak256(abi.encode(id, alice, uint8(1), salt));
vm.prank(alice);
ballot.commit(id, c, aliceProof);
vm.warp(commitEnd); // move into reveal phase
vm.prank(alice);
ballot.reveal(id, 1, salt);
assertEq(ballot.tally(id, 1), 1);
}
At a minimum, cover these cases:
- Ineligible addresses cannot commit, and invalid proofs revert.
- A voter cannot commit twice, reveal twice, or reveal a different option than committed.
- Commits after the deadline and reveals before or after the reveal window revert.
- Commitments cannot be replayed across elections or by other addresses.
- Tallies match the number of successful reveals (a good invariant for fuzz or invariant testing).
- Out-of-range options revert.
Add an end-to-end test that runs the frontend against a local node (Anvil or Hardhat Network) so you catch hash-encoding mismatches between viem and Solidity. That mismatch is a classic bug: every commit succeeds, and every reveal fails.
Deployment and auditing
Deploy first to a testnet that matches your target network, and run a full dress rehearsal election with real wallets, including mobile wallets over WalletConnect. Then, for mainnet or L2 deployment:
- Use scripted, reproducible deployments (Hardhat Ignition or Foundry scripts) and commit the deployment artifacts.
- Verify source code on the relevant block explorer so voters and observers can read exactly what they are interacting with.
- Move ownership to a multisig rather than a single hot wallet. Election creation is a privileged action and deserves more than one key.
- Decide on upgradeability deliberately. Upgradeable proxies let you fix bugs but also let admins change rules mid-election. For elections, immutable contracts with a new deployment per major version are often the more trustworthy choice.
- Publish the election parameters: contract address, Merkle root, the full eligibility list or its source, metadata hashes, and timestamps.
For anything binding, commission an independent audit and run static analysis tools such as Slither in CI. Audits do not guarantee safety, but they reliably catch the class of mistakes that internal teams become blind to. If you want help wiring contracts to a production frontend, our Web3 smart contract integration service covers exactly this handoff.
UX pitfalls to avoid
Technically sound voting systems still fail when voters get confused. The most common problems we see:
- Forgotten reveals. Send reminders (email, push, or in-app banners), show a clear countdown, and consider a relayer that can submit reveals on the voter’s behalf using a signed authorization.
- Wrong network. Detect the connected chain and offer a one-click switch with wagmi’s
useSwitchChaininstead of showing a cryptic error. - No gas tokens. New voters often have an empty wallet. Sponsored transactions or clear instructions for getting a small amount of gas are essential.
- Ambiguous transaction states. Distinguish “waiting for signature”, “pending on-chain” and “confirmed”, and link to the transaction so voters can verify it themselves.
- Raw error messages. Decode custom errors like
NotEligibleorWrongPhaseinto plain language. - Mobile blind spots. Many voters will use a phone. Test deep links into mobile wallets and the return flow back to your site.
- Unverifiable results. Show voters how to check the tally themselves: the contract address, the relevant events, and the eligibility root.
A voting system earns trust by being checkable, not by asking to be trusted. Every screen should make verification easier, not harder.
Conclusion
A blockchain voting dApp is a compact project with outsized stakes. Get the fundamentals right — a clear eligibility model, a small and well-tested ballot contract, honest communication about what commit–reveal does and does not hide, low-friction gas on an L2, and a Next.js frontend that turns wallet complexity into clear steps — and you will have something people can genuinely rely on. Start from a proven base like the Vote Chain theme, invest in tests and review, and rehearse the full election before it counts.
Planning a governance portal, a community election or a custom voting flow? Get in touch with our team and we will help you scope the contracts, frontend and audit path.
