Skip to content
Bridge Web2 to Web3: Building Your First dApp with Next.js and Ethers.js

Bridge Web2 to Web3: Building Your First dApp with Next.js and Ethers.js

7 min read
Web3Next.jsBlockchaindAppsEthereum

Bridge Web2 and Web3: Build your first dApp. This guide integrates Next.js with Ethers.js for blockchain interaction and practical deployment.

Introduction: The Dawn of Decentralized Applications (dApps)

The internet, as we know it, is undergoing a profound transformation. For decades, Web2 has dominated, characterized by centralized platforms and services where users often trade data privacy for convenience. However, a new paradigm is emerging: Web3. This evolution promises a decentralized, user-owned internet, powered by blockchain technology. At the heart of Web3 are Decentralized Applications, or dApps – applications that run on a peer-to-peer network rather than a single server, offering transparency, censorship resistance, and enhanced security.

For developers accustomed to the traditional Web2 stack, the leap into Web3 might seem daunting. But with modern frameworks and libraries, this transition is more accessible than ever. This article will guide you through building your first dApp, leveraging the power of Next.js for a robust frontend and Ethers.js for seamless interaction with the Ethereum blockchain.

Why Next.js is Your Go-To for dApp Frontends

Next.js has become an industry standard for building modern React applications, and its capabilities make it an ideal choice for dApp frontends. Here’s why:

  • Server-Side Rendering (SSR) & Static Site Generation (SSG): While dApps inherently rely on decentralized backends, a fast, SEO-friendly frontend is crucial for user adoption. Next.js's rendering strategies ensure your dApp's initial load is quick and performant, improving user experience and discoverability.
  • API Routes: Next.js allows you to create API endpoints within your application, which can be invaluable for handling server-side logic that might not directly interact with the blockchain, such as user authentication (if you're mixing Web2 elements) or data caching.
  • Optimized Performance: Features like automatic code splitting, image optimization, and pre-fetching contribute to a snappy user interface, essential for keeping users engaged, especially when dealing with potentially slower blockchain interactions.
  • Excellent Developer Experience: With built-in TypeScript support, fast refresh, and a vibrant community, Next.js provides a streamlined development workflow that allows you to focus on building features rather than configuring tooling.

By using Next.js, you're building a dApp that feels as fast and responsive as any traditional Web2 application, while benefiting from the decentralized assurances of Web3.

Demystifying Ethers.js: Your Gateway to the Blockchain

Ethers.js is a complete and compact library for interacting with the Ethereum blockchain and its ecosystem. It provides an intuitive interface for everything from sending transactions to interacting with smart contracts. Understanding its core components is key:

  • Providers: A Provider is an abstraction of a connection to the Ethereum network. It allows you to query blockchain state, such as getting account balances, transaction histories, or reading data from smart contracts. Common providers include JsonRpcProvider (for connecting to a node like Infura or Alchemy) and Web3Provider (for connecting to a browser wallet like MetaMask).
  • Signers: A Signer represents an Ethereum account that can sign messages and transactions. When you want to send Ether, deploy a contract, or call a contract function that modifies the blockchain state, you need a Signer. In a browser dApp, the user's connected wallet (e.g., MetaMask) acts as the Signer.
  • Contracts: Ethers.js allows you to interact with smart contracts deployed on the blockchain. You instantiate a Contract object by providing its address, its Application Binary Interface (ABI – essentially a JSON description of the contract's functions), and a Signer or Provider. This object then exposes methods corresponding to the smart contract's functions.

Together, these components form the backbone of your dApp's interaction with the Ethereum network.

Setting Up Your Development Environment

Before diving into code, let's set up our workspace:

  1. Node.js and npm/yarn

    Ensure you have Node.js (LTS version recommended) and a package manager (npm or yarn) installed.

    BASH
    node -v # Should output v18.x or higher npm -v  # Should output 9.x or higher
    
  2. Next.js Project Initialization

    Create a new Next.js project. We'll use TypeScript for better type safety, which is highly recommended for dApps.

    BASH
    npx create-next-app@latest my-dapp --typescript --eslint --app # Using the App Router
    

    Navigate into your new project directory:

    BASH
    cd my-dapp
    
  3. Hardhat for Smart Contract Development

    Hardhat is a powerful development environment for Ethereum smart contracts. It simplifies contract compilation, testing, and deployment. Install it as a dev dependency:

    BASH
    npm install --save-dev hardhat
    

    Initialize Hardhat in your project. Choose "Create a JavaScript project" for simplicity, or "Create a TypeScript project" if you prefer.

    BASH
    npx hardhat
    

    This will create a `hardhat.config.js` (or `.ts`) file, a `contracts` folder, and a `scripts` folder.

  4. Installing Ethers.js

    Install Ethers.js in your Next.js project:

    BASH
    npm install ethers
    

Step-by-Step: Building a Simple dApp (A Decentralized To-Do List)

Let's build a simple decentralized to-do list dApp. Users can add tasks, mark them as complete, and view all tasks.

1. Crafting Your Smart Contract (Solidity)

First, let's write our `Todo` smart contract in Solidity. Create `contracts/Todo.sol`:

VBNET
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Todo {    struct Task {        uint id;        string content;        bool completed;    }    Task[] public tasks;    uint public taskCount;    event TaskCreated(uint id, string content, bool completed);    event TaskCompleted(uint id, bool completed);    constructor() {        taskCount = 0; // Initialize taskCount    }    function createTask(string memory _content) public {        taskCount++;        tasks.push(Task(taskCount, _content, false));        emit TaskCreated(taskCount, _content, false);    }    function toggleCompleted(uint _id) public {        // Basic validation        require(_id > 0 && _id <= taskCount, "Invalid task ID.");        Task storage task = tasks[_id - 1]; // Array is 0-indexed        task.completed = !task.completed;        emit TaskCompleted(_id, task.completed);    }}

Compile your contract using Hardhat:

BASH
npx hardhat compile

This will create an `artifacts` folder containing the contract's ABI and bytecode, which we'll need for our Next.js app.

For deployment, you'd typically write a script in `scripts/deploy.js` and run `npx hardhat run scripts/deploy.js --network localhost` (after running `npx hardhat node`). For this tutorial, we'll assume the contract is deployed and you have its address and ABI.

2. Connecting to MetaMask: The Wallet Integration

We need a way for users to connect their MetaMask wallet. We'll create a context provider to manage wallet state globally. First, create a file `lib/constants.ts` to store our contract ABI and address:

BASH
// lib/constants.tsimport TodoArtifact from '../artifacts/contracts/Todo.sol/Todo.json';export const TODO_CONTRACT_ADDRESS = "0x...YourDeployedContractAddress..."; // Replace with your deployed contract address export const TODO_CONTRACT_ABI = TodoArtifact.abi;

Now, create `context/Web3Context.tsx`:

TYPESCRIPT
// context/Web3Context.tsximport React, { createContext, useState, useEffect, useContext } from 'react';import { ethers } from 'ethers';interface Web3ContextType {  provider: ethers.BrowserProvider | null;  signer: ethers.Signer | null;  account: string | null;  connectWallet: () => Promise<void>;  disconnectWallet: () => void;  chainId: number | null;}const Web3Context = createContext<Web3ContextType | undefined>(undefined);export const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children }) => {  const [provider, setProvider] = useState<(null);  const [signer, setSigner] = useState<(null);  const [account, setAccount] = useState<string | null>(null);  const [chainId, setChainId] = useState<number | null>(null);  const connectWallet = async () => {    if (window.ethereum) {      try {        const browserProvider = new ethers.BrowserProvider(window.ethereum);        setProvider(browserProvider);        const accounts = await browserProvider.send("eth_requestAccounts", []);        setAccount(accounts[0]);        const connectedSigner = await browserProvider.getSigner();        setSigner(connectedSigner);        const network = await browserProvider.getNetwork();        setChainId(Number(network.chainId));        // Handle account and network changes        window.ethereum.on('accountsChanged', (newAccounts: string[]) => {          setAccount(newAccounts[0] || null);          if (!newAccounts.length) {            disconnectWallet();          }        });        window.ethereum.on('chainChanged', (newChainId: string) => {          setChainId(Number(parseInt(newChainId, 16)));          // Potentially re-initialize provider/signer if needed based on logic        });      } catch (error) {        console.error("Error connecting to wallet:", error);        alert("Failed to connect wallet. Please ensure MetaMask is installed and unlocked.");      }    } else {      alert("MetaMask is not installed. Please install it to use this dApp.");    }  };  const disconnectWallet = () => {    setAccount(null);    setSigner(null);    setProvider(null);    setChainId(null);    // Optional: Remove event listeners if they were attached in a way that needs explicit removal  };  // Auto-connect if already connected (optional, but good UX)  useEffect(() => {    if (window.ethereum && window.ethereum.selectedAddress) {      connectWallet();    }  }, []);  const value = { provider, signer, account, connectWallet, disconnectWallet, chainId };  return <Web3Context.Provider value={value}>{children}</Web3Context.Provider>;};export const useWeb3 = () => {  const context = useContext(Web3Context);  if (context === undefined) {    throw new Error('useWeb3 must be used within a Web3Provider');  }  return context;};

Wrap your `_app.tsx` with this provider:

BASH
// pages/_app.tsximport type { AppProps } from 'next/app';import { Web3Provider } from '../context/Web3Context';function MyApp({ Component, pageProps }: AppProps) {  return (    <Web3Provider>      <Component {...pageProps} />    </Web3Provider>  );}export default MyApp;

3. Interacting with Your Smart Contract (Ethers.js in Next.js)

Now, let's create a component to display and manage our to-do list. We'll use the `useWeb3` hook from our context.

Create `components/TodoList.tsx`:

TYPESCRIPT
// components/TodoList.tsximport React, { useState, useEffect, useCallback } from 'react';import { useWeb3 } from '../context/Web3Context';import { ethers } from 'ethers';import { TODO_CONTRACT_ADDRESS, TODO_CONTRACT_ABI } from '../lib/constants';interface Task {  id: number;  content: string;  completed: boolean;}const TodoList: React.FC = () => {  const { provider, signer, account, connectWallet } = useWeb3();  const [todoContract, setTodoContract] = useState<(null);  const [tasks, setTasks] = useState<Task[]>([]);  const [newTaskContent, setNewTaskContent] = useState<string>('');  const [loading, setLoading] = useState<boolean>(false);  const [error, setError] = useState<string | null>(null);  // Initialize contract when provider/signer is available  useEffect(() => {    if (provider && signer) {      try {        const contract = new ethers.Contract(TODO_CONTRACT_ADDRESS, TODO_CONTRACT_ABI, signer);        setTodoContract(contract);      } catch (err) {        console.error("Error initializing contract:", err);        setError("Failed to load contract. Check address and ABI.");      }    } else {      setTodoContract(null);      setTasks([]);    }  }, [provider, signer]);  // Fetch tasks  const fetchTasks = useCallback(async () => {    if (todoContract) {      setLoading(true);      setError(null);      try {        const count = await todoContract.taskCount();        const fetchedTasks: Task[] = [];        for (let i = 1; i <= Number(count); i++) {          const task = await todoContract.tasks(i - 1); // Access array directly          fetchedTasks.push({            id: Number(task.id),            content: task.content,            completed: task.completed,          });        }        setTasks(fetchedTasks);      } catch (err) {        console.error("Error fetching tasks:", err);        setError("Failed to fetch tasks from contract.");      } finally {        setLoading(false);      }    }  }, [todoContract]);  // Add new task  const handleAddTask = async (e: React.FormEvent) => {    e.preventDefault();    if (!todoContract || !newTaskContent.trim() || !signer) {      setError("Please connect your wallet and enter a task.");      return;    }    setLoading(true);    setError(null);    try {      const tx = await todoContract.createTask(newTaskContent);      await tx.wait(); // Wait for the transaction to be mined      setNewTaskContent('');      await fetchTasks(); // Refresh tasks    } catch (err: any) {      console.error("Error adding task:", err);      setError(err.reason || "Failed to add task.");    } finally {      setLoading(false);    }  };  // Toggle task completion  const handleToggleCompleted = async (id: number) => {    if (!todoContract || !signer) {      setError("Please connect your wallet.");      return;    }    setLoading(true);    setError(null);    try {      const tx = await todoContract.toggleCompleted(id);      await tx.wait();      await fetchTasks(); // Refresh tasks    } catch (err: any) {      console.error("Error toggling task:", err);      setError(err.reason || "Failed to toggle task status.");    } finally {      setLoading(false);    }  };  // Fetch tasks on component mount and when contract is ready  useEffect(() => {    if (todoContract) {      fetchTasks();    }  }, [todoContract, fetchTasks]);  return (    <div>      <h1>Decentralized To-Do List</h1>      <div>        {account ? (          <p>Connected: <strong>{account.substring(0, 6)}...{account.substring(account.length - 4)}</strong></p>        ) : (          <button onClick={connectWallet}>Connect Wallet</button>        )}      </div>      {error && <p style={{ color: 'red' }}>Error: {error}</p>}      {loading && <p>Loading...</p>}      {account && (        <form onSubmit={handleAddTask}>          <input            type="text"            value={newTaskContent}            onChange={(e) => setNewTaskContent(e.target.value)}            placeholder="Add new task"            disabled={loading}          />          <button type="submit" disabled={loading}>Add Task</button>        </form>      )}      <h2>Tasks</h2>      {tasks.length === 0 && !loading && <p>No tasks found. Add one!</p>}      <ul>        {tasks.map((task) => (          <li key={task.id}>            <input              type="checkbox"              checked={task.completed}              onChange={() => handleToggleCompleted(task.id)}              disabled={loading || !account}            />            <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>              {task.content}            </span>          </li>        ))}      </ul>    </div>  );};export default TodoList;

4. Building the Next.js UI

Finally, integrate `TodoList.tsx` into your `pages/index.tsx` (or `app/page.tsx` for App Router):

BASH
// pages/index.tsx (for Pages Router) or app/page.tsx (for App Router)import TodoList from '../components/TodoList';export default function Home() {  return (    <div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>      <TodoList />    </div>  );}

Run your Next.js application:

BASH
npm run dev

Open your browser to `http://localhost:3000\`. You should now be able to connect your MetaMask wallet, add tasks, and toggle their completion status. Each interaction that changes the state (adding/toggling tasks) will trigger a MetaMask transaction confirmation.

Best Practices and Deployment Considerations

  • Error Handling: Implement robust error handling. Blockchain transactions can fail for many reasons (insufficient gas, reverted transactions). Provide clear feedback to the user.
  • Gas Optimization: Smart contract operations cost gas. For production dApps, ensure your contracts are optimized to minimize gas costs for users.
  • Security: Always audit your smart contracts. Use established libraries (e.g., OpenZeppelin) where possible. Be wary of common vulnerabilities like re-entrancy, integer overflow/underflow.
  • Frontend Deployment: Your Next.js frontend can be deployed to traditional hosting services like Vercel or Netlify. For a truly decentralized frontend, consider IPFS (InterPlanetary File System) via services like Pinata or Fleek.
  • Smart Contract Deployment: Deploy your smart contracts to a testnet (e.g., Sepolia) for thorough testing before moving to a mainnet (e.g., Ethereum Mainnet). Use a service like Infura or Alchemy as your node provider.
  • User Experience: Provide clear indications when transactions are pending, successful, or failed. Consider using a loading spinner or toast notifications.

Conclusion: The Future is Decentralized

Building a dApp is a fascinating journey that combines traditional web development skills with the cutting-edge world of blockchain. By leveraging powerful tools like Next.js and Ethers.js, you can create applications that are not only performant and user-friendly but also embody the core principles of decentralization: transparency, security, and user ownership.

This decentralized to-do list is just the beginning. The concepts learned here – wallet connection, contract interaction, and state management – are fundamental to building more complex dApps in DeFi, NFTs, gaming, and beyond. As Web3 continues to evolve, your skills in bridging Web2 and Web3 technologies will become increasingly valuable, positioning you at the forefront of the internet's next frontier. Dive in, experiment, and help shape the decentralized future!

Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.