Calling smart contracts from a frontend is one of the most common Web3 tasks. At the same time, many tutorials either overcomplicate things or hide important steps behind abstractions.
This article keeps a balanced approach:
- simple enough for developers new to Web3
- explicit and transparent enough for experienced developers
- no magic, no hidden logic
- lots of real code, step by step
We will use Next.js, ethers.js, and BSC Testnet.
What You Will Build
By the end of this article, you will have:
- a Next.js app
- MetaMask wallet connection
- a smart contract instance
- a function to read data from the contract
- a function to write data to the contract
- a real transaction on BSC Testnet
Prerequisites
You need:
- Node.js v16+
- MetaMask installed in your browser
- Basic React / JavaScript knowledge
- No Solidity knowledge required
Get Test BNB (Required)
To send transactions on BSC Testnet, you need test BNB.
Use the official faucet:
https://testnet.bnbchain.org/faucet-smart
Paste your wallet address and request funds.
Create a Next.js Project
Create a new project and install ethers:
npx create-next-app@latest ethers-nextjs-bsc --typescript
cd ethers-nextjs-bsc
npm install ethers
Start the dev server to verify everything works:
npm run dev
Wallet Connection with Ethers.js
Smart contracts are accessed through:
- a provider (read-only)
- a signer (for transactions)
Create a simple hook that exposes both.
// hooks/useWeb3.ts
import { useEffect, useState } from 'react'
import { ethers } from 'ethers'
export const useWeb3 = () => {
const [provider, setProvider] =
useState<ethers.BrowserProvider | null>(null)
const [signer, setSigner] =
useState<ethers.JsonRpcSigner | null>(null)
const [account, setAccount] = useState<string | null>(null)
const connectWallet = async () => {
if (!window.ethereum) {
alert('MetaMask is not installed')
return
}
const browserProvider =
new ethers.BrowserProvider(window.ethereum as any)
await browserProvider.send('eth_requestAccounts', [])
const signer = await browserProvider.getSigner()
const address = await signer.getAddress()
setProvider(browserProvider)
setSigner(signer)
setAccount(address)
}
useEffect(() => {
if (!window.ethereum) return
window.ethereum.on('accountsChanged', connectWallet)
window.ethereum.on('chainChanged', () => window.location.reload())
}, [])
return { provider, signer, account, connectWallet }
}
Nothing is hidden here:
- MetaMask is requested explicitly
- provider and signer are created manually
- state is stored in React
Smart Contract Used in This Guide
We will use a simple contract deployed on BSC Testnet.
Contract address:
0xf22063aC68185A967eb71a2f5b877336b64bF9E1
The contract exposes:
-
greet()→ returns a string -
setGreeting(string)→ updates the greeting
Contract Helper File
Create a helper to reuse the contract instance.
// utils/contract.ts
import { ethers } from 'ethers'
export const CONTRACT_ADDRESS =
'0xf22063aC68185A967eb71a2f5b877336b64bF9E1'
export const CONTRACT_ABI = [
'function greet() view returns (string)',
'function setGreeting(string _greeting)',
]
export const getContract = (
signerOrProvider: ethers.Signer | ethers.Provider
) => {
return new ethers.Contract(
CONTRACT_ADDRESS,
CONTRACT_ABI,
signerOrProvider
)
}
This is exactly how ethers.js expects contracts to be instantiated.
Full Page Example (Read + Write)
Below is a complete page showing everything together.
// pages/index.tsx
import { useEffect, useState } from 'react'
import { useWeb3 } from '../hooks/useWeb3'
import { getContract } from '../utils/contract'
export default function Home() {
const { provider, signer, account, connectWallet } = useWeb3()
const [greeting, setGreeting] = useState('')
const [newGreeting, setNewGreeting] = useState('')
const [loading, setLoading] = useState(false)
const loadGreeting = async () => {
if (!provider) return
const contract = getContract(provider)
const value = await contract.greet()
setGreeting(value)
}
const updateGreeting = async () => {
if (!signer) {
alert('Connect wallet first')
return
}
try {
setLoading(true)
const contract = getContract(signer)
const tx = await contract.setGreeting(newGreeting)
await tx.wait()
await loadGreeting()
} finally {
setLoading(false)
}
}
useEffect(() => {
loadGreeting()
}, [provider])
return (
<main style={{ padding: 32 }}>
{!account ? (
<button onClick={connectWallet}>
Connect Wallet
</button>
) : (
<p>Connected: {account}</p>
)}
<h2>Greeting from contract</h2>
<p>{greeting || '—'}</p>
<input
value={newGreeting}
onChange={(e) => setNewGreeting(e.target.value)}
placeholder="New greeting"
/>
<button onClick={updateGreeting} disabled={loading}>
{loading ? 'Sending...' : 'Update Greeting'}
</button>
</main>
)
}
This example:
- reads contract state
- sends a transaction
- waits for confirmation
- updates UI after mining
Network Reminder
This example works on BSC Testnet (chain ID 97).
If MetaMask is connected to a different network:
- contract calls will fail
- transactions will not be sent
Final Thoughts
Calling smart contracts with ethers.js does not require heavy abstractions.
Once you understand:
- provider vs signer
- contract address + ABI
- read vs write calls
You can interact with almost any EVM smart contract from a frontend app.
This pattern works the same way on Ethereum, BSC, Polygon, and other EVM chains.
Top comments (0)