In this manual, I will cover the most effective tools for developing decentralised applications on the Ethereum blockchain. I built numerous projects with this stack, so I am confident that they are optimal for current development practices.
UI
Foundation: Next.js
We'll use the open-source web development framework Next.js as the foundation of our UI. That's a better option than pure React because it includes a lot of things out of the box, such as optimizations, polyfills, integration examples, and much more. This will save us a lot of time so we can focus on actual development.
EVM Utilities: viem
To interact with Ethereum, we will use viem, a modern fully type-safe TypeScript interface that provides low-level stateless primitives for blockchain interactions. It contains everything we'll need, from transaction and log utilities to functions for ENS resolving and much more.
Key Features
Here is a list of Viem's major advantages:
- It is highly performant and much smaller in size than alternatives, which is an important consideration for front-end app bundles.
- It can automatically infer types from the provided ABI without requiring any additional steps or packages, such as typechain for ethers. Just don't forget to export ABIs
as const
! - It is fully modular and easily extensible. You get two client primitives: public (for public actions) and wallet (for signing actions), which can be extended or customised to your liking.
- Viem supports contract wallet signature verification (ERC-1271) as well as regular EOA signatures via
publicClient.verifyMessage()
, allowing you to prepare for Account Abstraction mainstream adoption. - On RPC errors, viem logs detailed explanations of what happened rather than just throwing a generic error message or the error ID, saving you a lot of debugging time.
Extensions
There is also excellent tooling to extend viem.
For example, Pimlico's permissionless offers clients additional functions for interacting with the ERC-4337 Account Abstraction infrastructure. It allows you to deploy smart accounts, create User Operations, send them to a bundler, and sponsor gas with a paymaster.
// Import the required modules.
import { createBundlerClient } from "permissionless"
import { sepolia } from "viem/chains"
import { http } from "viem"
// Create the required clients.
const bundlerClient = createBundlerClient({
chain: sepolia,
transport: http(bundlerUrl), // Use any bundler url
entryPoint: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
})
// Consume bundler, paymaster, and smart account actions!
const opReceipt = await bundlerClient.getUserOperationReceipt({
hash: userOpHash
})
// Build with strict TypeScript types
opReceipt!.actualGasUsed // actualGasUsed: bigint
One more example is reversemirage, which provides clients with a set of functions for the most common token standards, such as ERC-20. For example, for ERC-20, you would get helpers such as getERC20BalanceOf
for public clients and writeERC20Transfer
for wallet clients, eliminating the need to manually import and pass the ABI.
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { publicActionsReverseMirage, amountToNumber } from 'reverse-mirage'
export const publicClient = createPublicClient({
chain: mainnet,
transport: http()
}).extend(publicActionsReverseMirage)
// read token metadata
const usdc = await publicClient.getERC20({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // usdc
id: mainnet.id,
})
console.log(usdc.decimals) // 6
console.log(usdc.name) // USD Coin
// read a balance
const vitalikBalance = await publicClient.getERC20Balance({
erc20: usdc,
address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' // vitalik
})
console.log(vitalikBalance.amount) // 420690000n
console.log(amountToNumber(vitalikBalance)) // 420.69
Viem is also starting to support L2s such as Optimism and zkSync through extensions.
import { createPublicClient, http, parseEther } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({
chain: mainnet,
transport: http(),
}).extend(publicActionsL2())
const l1Gas = await client.estimateL1Gas({
account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',
to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
value: parseEther('1')
})
React Hooks: wagmi
We are going with wagmi for hooks, and I'll explain why below.
import { WagmiProvider } from 'wagmi'
import { http, createConfig } from 'wagmi'
import { mainnet } from 'wagmi/chains'
export const config = createConfig({
chains: [mainnet],
transports: {
[mainnet.id]: http(),
},
})
function App() {
return (
<WagmiProvider config={config}>
{/** ... */}
</WagmiProvider>
)
}
First and foremost, it ensures full type safety for hooks through ABI and EIP-712 Typed Data type inference.
Wagmi is built on Viem, so we're not adding any major dependencies to our project other than TanStack Query.
Wagmi benefits from TanStack Query's caching and state deduplication. By default, cache is in memory, but persistent storage can be configured to store state in localStorage.
Wagmi supports auto state refresh on network and address changes, as well as built-in automatic multicall support, which improves data fetching speed for contracts with multiple interactions.
It already fully supports EIP-6963, which is a significant improvement to the wallet connection experience by allowing wallets to advertise themselves to wagmi rather than gambling for access to window.ethereum
.
One of Wagmi's lesser-known features is its CLI, which enables custom hook generation from provided ABIs. You can pre-generate hooks for your contracts, eliminating the need to import and pass ABIs and contract addresses, which is quite useful.
Data Fetching Hooks: TanStack Query
Because wagmi 2.0 added TanStack Query as a peer dependency, we'll use it for any other data fetching, such as data from our indexer or third-party API services.
Although SWR is a good alternative, there are no significant features or API differences to justify using both data fetching libraries in the same app.
Backend for Contract Indexing: Ponder
Intro
Often, fetching contract data on each page load is inefficient or we must compute aggregated data. We'll need a separate indexing backend that will track the chain head and automatically update the database.
We are using Ponder for this.
Ponder, like most indexers, provides a GraphQL API. For fetching data, I recommend creating a custom TanStack Query hook on the frontend and using graphql-request as a fetcher due to its small footprint.
Features
It is based on viem and ABIType, so we'll have the same familiar API for accessing event data and making on-chain requests, complete with excellent type safety and autocomplete.
One of Ponder's standout features is its excellent development server. It allows for hot reloading of all project files and provides detailed error logs.
Ponder runs in Node.js, so we can import NPM packages, make fetch requests while indexing, and even connect to external databases if we want to.
It is also multi-chain, allowing it to index and aggregate data in a single database across multiple chains at once.
Speed
Ponder claims that it is also very fast: indexing an ERC-20 contract is approximately 10 times faster than the Subgraph Graph Node from a cold start and 15 times faster when fully cached.
Keep in mind, however, that Subgraphs are specifically designed for the Decentralized Network, so speed and flexibility are intentionally sacrificed in favour of perfectly reproducible indexing.
Deployment
In terms of infrastructure, Ponder uses SQLite for development, but it will require a PostgreSQL database to store data for production use.
Ponder offers one-click deployments on services such as Railway, but it can also be hosted on your own server with Docker. It also supports zero-downtime deployments by performing a health check that returns "healthy" only after all events have been indexed. Built-in Prometheus metrics and detailed logs allow you to monitor indexing progress and identify indexing errors.
Developer Experience
In contrast to Subgraphs, you'll see a single auto-generated indexer instance rather than having to manually import each event and entity.
import { ponder } from "@/generated";
ponder.on("Blitmap:Transfer", async ({ event, context }) => {
const { BlitmapToken } = context.db;
await BlitmapToken.create({
id: event.args.tokenId,
data: {
owner: event.args.to,
},
});
});
One of the patterns I personally liked when working with Ponder is built-in upsert function for entities, where from one function call you can provide different logic for creating and updating an entity:
// Create an Account for the sender, or update the balance if it already exists.
await Account.upsert({
id: event.args.from,
create: {
balance: BigInt(0),
isOwner: false,
},
update: ({ current }) => ({
balance: current.balance - event.args.value,
}),
});
Conclusion
In this article, I've used my practical experience and insights to provide a streamlined approach to developing decentralised applications on Ethereum. I've presented a robust and efficient framework that combines the best features of Next.js, Viem, Wagmi, and Ponder with advanced extensions and tools. Using this stack, developers can ensure that their DApps are not only scalable and secure, but also stay ahead of the rapidly evolving blockchain ecosystem.
Top comments (10)
Very interesting and useful article. Keep up the good work 👍 Everything will be fine :)
très cool article intéressant tout est super
very interesting article I liked it. I learned a lot of new things
The post is just a fire super bomb score👍
there are quite interesting presentations of situations and their solutions, which is very pleasing
Interesting posts, very good and like posts
thanks for the information like me for the post
Interesting posts, very good and like posts
Very interesting and useful article. thank you very much for your work
Good article, it turned out interesting, a lot of useful and interesting