Building a Full-Stack Web3 dApp with Wagmi and Next.js 15
Building a Full-Stack Web3 dApp with Wagmi and Next.js 15
The Web3 ecosystem has matured significantly in 2025, with developer tools becoming more sophisticated and user-friendly. In this comprehensive guide, we'll build a complete decentralized application (dApp) from scratch, covering everything from smart contract development to frontend integration. By the end, you'll have a production-ready dApp that users can interact with through their Web3 wallets.
Understanding the Modern Web3 Stack
Before diving into code, let's understand the technologies we'll be using and why they represent the current best practices in Web3 development.
Wagmi v2 and viem
Wagmi has become the de facto standard for React-based Ethereum applications. Version 2, built on top of viem, offers significant improvements over its predecessor:
- TypeScript-first design: Full type safety from smart contracts to UI
- Modular architecture: Use only what you need
- Better performance: viem's lightweight RPC client
- Improved hooks: More intuitive APIs for common patterns
Viem serves as the underlying Ethereum library, replacing ethers.js and web3.js for most new projects. It provides a lean, type-safe interface for interacting with Ethereum nodes.
Next.js 15 App Router
Next.js 15's App Router provides the perfect foundation for Web3 applications:
- Server Components: Handle sensitive operations server-side
- API Routes: Create secure backend endpoints
- Middleware: Implement authentication and route protection
- Edge Runtime: Fast response times for global users
Project Setup
Let's start by setting up our project. We'll create a Next.js 15 application with all the necessary Web3 dependencies.
Installation and Configuration
# Create Next.js project
npx create-next-app@latest web3-dapp --typescript --tailwind --app
# Navigate to project
cd web3-dapp
# Install Web3 dependencies
npm install wagmi viem@latest @tanstack/react-query
npm install @rainbow-me/rainbowkit
Configuring the Wagmi Client
The configuration file is crucial for connecting your dApp to various blockchain networks and wallet providers. Create a comprehensive configuration that supports multiple chains and wallet options:
// config/wagmi.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base, optimism } from 'wagmi/chains';
import { injected, metaMask, walletConnect, coinbaseWallet } from 'wagmi/connectors';
const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!;
export const config = createConfig({
chains: [mainnet, polygon, arbitrum, base, optimism],
connectors: [
injected(),
metaMask(),
coinbaseWallet({
appName: 'Web3 dApp',
}),
walletConnect({
projectId,
}),
],
transports: {
[mainnet.id]: http(
`https://eth-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`
),
[polygon.id]: http(
`https://polygon-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`
),
[arbitrum.id]: http(
`https://arb-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`
),
[base.id]: http(
`https://base-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`
),
[optimism.id]: http(
`https://opt-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`
),
},
ssr: true, // Enable server-side rendering
});
// Export types for TypeScript inference
export type Config = typeof config;
declare module 'wagmi' {
interface Register {
config: Config;
}
}
Setting Up Providers
Web3 applications require careful provider setup, especially with Next.js's server-side rendering. We need to ensure our providers work correctly in both server and client contexts:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { config } from '@/config/wagmi';
import { useState } from 'react';
import '@rainbow-me/rainbowkit/styles.css';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
refetchOnWindowFocus: false,
},
},
})
);
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
theme={darkTheme({
accentColor: '#7b3ff2',
accentColorForeground: 'white',
borderRadius: 'medium',
})}
modalSize="compact"
>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
Smart Contract Development
A great dApp starts with well-designed smart contracts. We'll create a simple but feature-rich NFT marketplace contract that demonstrates common patterns you'll encounter in Web3 development.
Contract Architecture
Our contract will implement:
- ERC-721 token minting with metadata
- Fixed-price listings
- Auction functionality
- Royalty distribution
- Pausable functionality for emergency stops
// contracts/NFTMarketplace.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/interfaces/IERC2981.sol";
/**
* @title NFTMarketplace
* @dev A comprehensive NFT marketplace with minting, fixed-price sales, and auctions
* @author Web3 Developer
*/
contract NFTMarketplace is
ERC721,
ERC721URIStorage,
Ownable,
Pausable,
ReentrancyGuard,
IERC2981
{
// ============ State Variables ============
uint256 private _tokenIdCounter;
uint256 private _listingIdCounter;
uint256 private _auctionIdCounter;
// Platform fee (2.5%)
uint256 public platformFee = 250; // Basis points
address public feeRecipient;
// Mapping from token ID to creator
mapping(uint256 => address) public tokenCreators;
// Mapping from token ID to royalty percentage
mapping(uint256 => uint256) public tokenRoyalties;
// ============ Structs ============
struct Listing {
uint256 tokenId;
address seller;
uint256 price;
bool active;
uint256 createdAt;
}
struct Auction {
uint256 tokenId;
address seller;
uint256 startingPrice;
uint256 highestBid;
address highestBidder;
uint256 endTime;
bool active;
bool settled;
}
// ============ Mappings ============
mapping(uint256 => Listing) public listings;
mapping(uint256 => Auction) public auctions;
mapping(address => uint256) public pendingWithdrawals;
// ============ Events ============
event NFTMinted(
uint256 indexed tokenId,
address indexed creator,
string tokenURI,
uint256 royaltyPercentage
);
event ListingCreated(
uint256 indexed listingId,
uint256 indexed tokenId,
address indexed seller,
uint256 price
);
event ListingSold(
uint256 indexed listingId,
uint256 indexed tokenId,
address buyer,
uint256 price
);
event AuctionCreated(
uint256 indexed auctionId,
uint256 indexed tokenId,
uint256 startingPrice,
uint256 endTime
);
event BidPlaced(
uint256 indexed auctionId,
address indexed bidder,
uint256 amount
);
event AuctionSettled(
uint256 indexed auctionId,
address winner,
uint256 finalPrice
);
// ============ Constructor ============
constructor(
string memory name_,
string memory symbol_,
address initialOwner,
address feeRecipient_
) ERC721(name_, symbol_) Ownable(initialOwner) {
feeRecipient = feeRecipient_;
}
// ============ Minting Functions ============
/**
* @dev Mints a new NFT with specified metadata and royalty
* @param to Recipient of the NFT
* @param tokenURI_ Metadata URI
* @param royaltyPercentage Royalty percentage in basis points (max 10%)
*/
function mintNFT(
address to,
string memory tokenURI_,
uint256 royaltyPercentage
) external whenNotPaused returns (uint256) {
require(royaltyPercentage <= 1000, "Royalty too high");
uint256 tokenId = _tokenIdCounter++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenURI_);
tokenCreators[tokenId] = msg.sender;
tokenRoyalties[tokenId] = royaltyPercentage;
emit NFTMinted(tokenId, msg.sender, tokenURI_, royaltyPercentage);
return tokenId;
}
// ============ Listing Functions ============
/**
* @dev Creates a fixed-price listing for an NFT
* @param tokenId Token ID to list
* @param price Price in wei
*/
function createListing(
uint256 tokenId,
uint256 price
) external whenNotPaused {
require(ownerOf(tokenId) == msg.sender, "Not token owner");
require(price > 0, "Price must be positive");
require(getApproved(tokenId) == address(this), "Not approved");
uint256 listingId = _listingIdCounter++;
listings[listingId] = Listing({
tokenId: tokenId,
seller: msg.sender,
price: price,
active: true,
createdAt: block.timestamp
});
emit ListingCreated(listingId, tokenId, msg.sender, price);
}
/**
* @dev Purchases an NFT from a listing
* @param listingId ID of the listing
*/
function buyListing(uint256 listingId)
external
payable
nonReentrant
whenNotPaused
{
Listing storage listing = listings[listingId];
require(listing.active, "Listing not active");
require(msg.value >= listing.price, "Insufficient payment");
require(ownerOf(listing.tokenId) == listing.seller, "Seller no longer owns NFT");
listing.active = false;
// Calculate fees and royalties
uint256 royaltyAmount = (msg.value * tokenRoyalties[listing.tokenId]) / 10000;
uint256 platformFeeAmount = (msg.value * platformFee) / 10000;
uint256 sellerProceeds = msg.value - royaltyAmount - platformFeeAmount;
// Transfer NFT to buyer
_transfer(listing.seller, msg.sender, listing.tokenId);
// Distribute payments
pendingWithdrawals[listing.seller] += sellerProceeds;
pendingWithdrawals[tokenCreators[listing.tokenId]] += royaltyAmount;
pendingWithdrawals[feeRecipient] += platformFeeAmount;
// Refund excess payment
if (msg.value > listing.price) {
pendingWithdrawals[msg.sender] += msg.value - listing.price;
}
emit ListingSold(listingId, listing.tokenId, msg.sender, listing.price);
}
// ============ Auction Functions ============
/**
* @dev Creates an auction for an NFT
* @param tokenId Token ID to auction
* @param startingPrice Minimum bid amount
* @param duration Auction duration in seconds
*/
function createAuction(
uint256 tokenId,
uint256 startingPrice,
uint256 duration
) external whenNotPaused {
require(ownerOf(tokenId) == msg.sender, "Not token owner");
require(duration >= 1 hours && duration <= 7 days, "Invalid duration");
require(getApproved(tokenId) == address(this), "Not approved");
uint256 auctionId = _auctionIdCounter++;
auctions[auctionId] = Auction({
tokenId: tokenId,
seller: msg.sender,
startingPrice: startingPrice,
highestBid: 0,
highestBidder: address(0),
endTime: block.timestamp + duration,
active: true,
settled: false
});
emit AuctionCreated(auctionId, tokenId, startingPrice, block.timestamp + duration);
}
/**
* @dev Places a bid on an active auction
* @param auctionId ID of the auction
*/
function placeBid(uint256 auctionId) external payable nonReentrant whenNotPaused {
Auction storage auction = auctions[auctionId];
require(auction.active, "Auction not active");
require(block.timestamp < auction.endTime, "Auction ended");
require(msg.sender != auction.seller, "Seller cannot bid");
require(msg.value > auction.highestBid, "Bid too low");
require(msg.value >= auction.startingPrice, "Below starting price");
// Refund previous highest bidder
if (auction.highestBidder != address(0)) {
pendingWithdrawals[auction.highestBidder] += auction.highestBid;
}
auction.highestBid = msg.value;
auction.highestBidder = msg.sender;
// Extend auction if bid placed in last 5 minutes
if (auction.endTime - block.timestamp < 5 minutes) {
auction.endTime += 5 minutes;
}
emit BidPlaced(auctionId, msg.sender, msg.value);
}
/**
* @dev Settles an ended auction
* @param auctionId ID of the auction to settle
*/
function settleAuction(uint256 auctionId) external nonReentrant {
Auction storage auction = auctions[auctionId];
require(auction.active, "Auction not active");
require(block.timestamp >= auction.endTime, "Auction not ended");
require(!auction.settled, "Already settled");
auction.settled = true;
auction.active = false;
if (auction.highestBidder != address(0)) {
// Calculate fees and royalties
uint256 royaltyAmount = (auction.highestBid * tokenRoyalties[auction.tokenId]) / 10000;
uint256 platformFeeAmount = (auction.highestBid * platformFee) / 10000;
uint256 sellerProceeds = auction.highestBid - royaltyAmount - platformFeeAmount;
// Transfer NFT to winner
_transfer(auction.seller, auction.highestBidder, auction.tokenId);
// Distribute payments
pendingWithdrawals[auction.seller] += sellerProceeds;
pendingWithdrawals[tokenCreators[auction.tokenId]] += royaltyAmount;
pendingWithdrawals[feeRecipient] += platformFeeAmount;
emit AuctionSettled(auctionId, auction.highestBidder, auction.highestBid);
} else {
// No bids, return NFT to seller
_transfer(auction.seller, auction.seller, auction.tokenId);
}
}
// ============ Withdrawal Functions ============
/**
* @dev Withdraws accumulated balance
*/
function withdraw() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Withdrawal failed");
}
// ============ Admin Functions ============
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
function setPlatformFee(uint256 newFee) external onlyOwner {
require(newFee <= 500, "Fee too high");
platformFee = newFee;
}
// ============ Royalty Functions ============
function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view override returns (address receiver, uint256 royaltyAmount) {
receiver = tokenCreators[tokenId];
royaltyAmount = (salePrice * tokenRoyalties[tokenId]) / 10000;
}
// ============ Overrides ============
function tokenURI(
uint256 tokenId
) public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(
bytes4 interfaceId
) public view override(ERC721, ERC721URIStorage, IERC165) returns (bool) {
return
interfaceId == type(IERC2981).interfaceId ||
super.supportsInterface(interfaceId);
}
}
Frontend Integration
Now that we have our smart contract, let's build the frontend application that interacts with it. We'll create a complete user interface with wallet connection, NFT display, and transaction handling.
Wallet Connection Component
The wallet connection is the entry point for any Web3 application. We need a robust component that handles various wallet states and provides clear feedback to users:
// components/WalletConnect.tsx
'use client';
import { useAccount, useConnect, useDisconnect, useBalance, useChainId } from 'wagmi';
import { useSwitchChain } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useState, useEffect } from 'react';
export function WalletConnect() {
const { address, isConnected, isConnecting } = useAccount();
const { disconnect } = useDisconnect();
const { data: balance } = useBalance({ address });
const chainId = useChainId();
const { switchChain, isPending: isSwitching } = useSwitchChain();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="animate-pulse h-12 w-40 bg-gray-700 rounded-lg" />;
}
if (!isConnected) {
return <ConnectButton />;
}
const supportedChains = [mainnet, polygon, arbitrum, base];
const currentChain = supportedChains.find(c => c.id === chainId);
return (
<div className="flex flex-col gap-4 p-4 bg-gray-800 rounded-xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
{address?.slice(2, 4).toUpperCase()}
</div>
<div>
<p className="text-white font-mono text-sm">
{address?.slice(0, 6)}...{address?.slice(-4)}
</p>
{balance && (
<p className="text-gray-400 text-xs">
{parseFloat(balance.formatted).toFixed(4)} {balance.symbol}
</p>
)}
</div>
</div>
<button
onClick={() => disconnect()}
className="px-4 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
>
Disconnect
</button>
</div>
{/* Chain Switcher */}
{currentChain && (
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">Network:</span>
<select
value={chainId}
onChange={(e) => switchChain({ chainId: Number(e.target.value) })}
disabled={isSwitching}
className="bg-gray-700 text-white px-3 py-1.5 rounded-lg text-sm disabled:opacity-50"
>
{supportedChains.map((chain) => (
<option key={chain.id} value={chain.id}>
{chain.name}
</option>
))}
</select>
</div>
)}
</div>
);
}
NFT Gallery Component
Displaying NFTs efficiently requires proper caching and handling of various metadata formats. We'll create a gallery component that fetches and displays NFTs with loading states and error handling:
// components/NFTGallery.tsx
'use client';
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { useState, useEffect } from 'react';
import { parseEther, formatEther } from 'viem';
import { NFT_MARKETPLACE_ABI, NFT_MARKETPLACE_ADDRESS } from '@/contracts/NFTMarketplace';
interface NFT {
id: bigint;
uri: string;
owner: string;
creator: string;
royalty: bigint;
}
interface Listing {
tokenId: bigint;
seller: string;
price: bigint;
active: boolean;
}
interface NFTMetadata {
name: string;
description: string;
image: string;
attributes: Array<{ trait_type: string; value: string }>;
}
export function NFTGallery() {
const { address, isConnected } = useAccount();
const [selectedNFT, setSelectedNFT] = useState<NFT | null>(null);
const [listingPrice, setListingPrice] = useState('');
const [activeTab, setActiveTab] = useState<'gallery' | 'listings' | 'auctions'>('gallery');
// Fetch user's NFTs
const { data: userNFTs, refetch: refetchNFTs } = useReadContract({
address: NFT_MARKETPLACE_ADDRESS,
abi: NFT_MARKETPLACE_ABI,
functionName: 'getTokensByOwner',
args: [address],
query: {
enabled: !!address,
},
});
// Fetch all active listings
const { data: listings, refetch: refetchListings } = useReadContract({
address: NFT_MARKETPLACE_ADDRESS,
abi: NFT_MARKETPLACE_ABI,
functionName: 'getActiveListings',
query: {
refetchInterval: 10000, // Refetch every 10 seconds
},
});
// Write contract for listing NFT
const {
data: listingHash,
writeContract: listNFT,
isPending: isListing
} = useWriteContract();
const { isLoading: isListingConfirming, isSuccess: listingSuccess } =
useWaitForTransactionReceipt({ hash: listingHash });
// Handle listing creation
const handleCreateListing = async (tokenId: bigint) => {
if (!listingPrice || !address) return;
try {
await listNFT({
address: NFT_MARKETPLACE_ADDRESS,
abi: NFT_MARKETPLACE_ABI,
functionName: 'createListing',
args: [tokenId, parseEther(listingPrice)],
});
} catch (error) {
console.error('Failed to create listing:', error);
}
};
// Purchase listing
const {
writeContract: buyNFT,
isPending: isBuying
} = useWriteContract();
const handleBuy = async (listingId: bigint, price: bigint) => {
try {
await buyNFT({
address: NFT_MARKETPLACE_ADDRESS,
abi: NFT_MARKETPLACE_ABI,
functionName: 'buyListing',
args: [listingId],
value: price,
});
} catch (error) {
console.error('Failed to buy NFT:', error);
}
};
// Fetch NFT metadata
const fetchMetadata = async (uri: string): Promise<NFTMetadata | null> => {
try {
// Handle IPFS URIs
const httpUri = uri.replace('ipfs://', 'https://ipfs.io/ipfs/');
const response = await fetch(httpUri);
return await response.json();
} catch {
return null;
}
};
useEffect(() => {
if (listingSuccess) {
setListingPrice('');
setSelectedNFT(null);
refetchNFTs();
refetchListings();
}
}, [listingSuccess, refetchNFTs, refetchListings]);
if (!isConnected) {
return (
<div className="text-center py-12">
<p className="text-gray-400">Connect your wallet to view your NFTs</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Tab Navigation */}
<div className="flex gap-2">
{(['gallery', 'listings', 'auctions'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-lg transition-colors ${
activeTab === tab
? 'bg-purple-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Gallery Tab */}
{activeTab === 'gallery' && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{userNFTs && userNFTs.length > 0 ? (
userNFTs.map((nft: NFT) => (
<NFTCard
key={nft.id.toString()}
nft={nft}
onList={() => setSelectedNFT(nft)}
showListButton
/>
))
) : (
<div className="col-span-full text-center py-12">
<p className="text-gray-400">You don't have any NFTs yet</p>
</div>
)}
</div>
)}
{/* Listings Tab */}
{activeTab === 'listings' && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{listings && listings.length > 0 ? (
listings.map((listing: Listing) => (
<ListingCard
key={listing.tokenId.toString()}
listing={listing}
onBuy={() => handleBuy(listing.tokenId, listing.price)}
isBuying={isBuying}
/>
))
) : (
<div className="col-span-full text-center py-12">
<p className="text-gray-400">No active listings</p>
</div>
)}
</div>
)}
{/* Listing Modal */}
{selectedNFT && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-2xl p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">List NFT for Sale</h3>
<div className="mb-4">
<label className="block text-gray-400 text-sm mb-2">Price (ETH)</label>
<input
type="number"
value={listingPrice}
onChange={(e) => setListingPrice(e.target.value)}
placeholder="0.1"
step="0.001"
min="0"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-purple-500"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => handleCreateListing(selectedNFT.id)}
disabled={!listingPrice || isListing || isListingConfirming}
className="flex-1 px-4 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isListing || isListingConfirming ? 'Listing...' : 'Create Listing'}
</button>
<button
onClick={() => setSelectedNFT(null)}
className="px-4 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
// NFT Card Component
function NFTCard({
nft,
onList,
showListButton
}: {
nft: NFT;
onList?: () => void;
showListButton?: boolean;
}) {
const [metadata, setMetadata] = useState<NFTMetadata | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMetadata(nft.uri).then((data) => {
setMetadata(data);
setLoading(false);
});
}, [nft.uri]);
if (loading) {
return (
<div className="aspect-square bg-gray-800 rounded-xl animate-pulse" />
);
}
return (
<div className="bg-gray-800 rounded-xl overflow-hidden group">
<div className="aspect-square relative">
{metadata?.image ? (
<img
src={metadata.image.replace('ipfs://', 'https://ipfs.io/ipfs/')}
alt={metadata.name || `NFT #${nft.id.toString()}`}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-purple-500/20 to-pink-500/20">
<span className="text-4xl">🖼️</span>
</div>
)}
</div>
<div className="p-4">
<h4 className="text-white font-semibold truncate">
{metadata?.name || `NFT #${nft.id.toString()}`}
</h4>
{metadata?.description && (
<p className="text-gray-400 text-sm mt-1 line-clamp-2">
{metadata.description}
</p>
)}
{showListButton && onList && (
<button
onClick={onList}
className="mt-3 w-full py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
List for Sale
</button>
)}
</div>
</div>
);
}
// Listing Card Component
function ListingCard({
listing,
onBuy,
isBuying
}: {
listing: Listing;
onBuy: () => void;
isBuying: boolean;
}) {
return (
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="p-4">
<h4 className="text-white font-semibold">
NFT #{listing.tokenId.toString()}
</h4>
<p className="text-gray-400 text-sm mt-1">
Seller: {listing.seller.slice(0, 6)}...{listing.seller.slice(-4)}
</p>
<p className="text-purple-400 font-bold mt-2">
{formatEther(listing.price)} ETH
</p>
<button
onClick={onBuy}
disabled={isBuying}
className="mt-3 w-full py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{isBuying ? 'Buying...' : 'Buy Now'}
</button>
</div>
</div>
);
}
Best Practices and Security Considerations
Building secure Web3 applications requires careful attention to several areas. Let's cover the most important considerations:
Smart Contract Security
- Reentrancy Protection: Always use
ReentrancyGuardfor functions that transfer ETH or tokens - Access Control: Use OpenZeppelin's
OwnableandAccessControlfor privileged functions - Integer Overflow: Solidity 0.8+ handles this automatically, but be careful with unchecked blocks
- Gas Limit Considerations: Avoid unbounded loops that could exceed block gas limits
Frontend Security
- Input Validation: Always validate user inputs before sending to smart contracts
- Transaction Simulation: Use
simulateContractto preview transactions before signing - Error Handling: Provide clear error messages for failed transactions
- Private Key Safety: Never expose private keys or seed phrases in frontend code
User Experience Best Practices
- Transaction Status: Show clear loading states during transaction confirmation
- Network Switching: Help users switch to the correct network automatically
- Gas Estimation: Display estimated gas costs before transaction submission
- Transaction History: Keep track of pending and completed transactions
Conclusion
Building a full-stack Web3 application requires understanding multiple layers of technology, from smart contract development to frontend integration. The stack we've covered—Wagmi, viem, and Next.js 15—represents the current best practices for Web3 development in 2025.
Key takeaways from this guide:
- Use Type-Safe Tools: Wagmi and viem provide excellent TypeScript support, catching errors at compile time
- Design for Users: Web3 UX is still evolving; prioritize clear feedback and simple interactions
- Think About Gas: Every on-chain action costs money; optimize your contract functions
- Security First: Smart contracts are immutable; audit thoroughly before deployment
- Stay Updated: The Web3 ecosystem moves fast; keep your dependencies current
The complete code for this project is available on GitHub, including deployment scripts for various testnets and mainnets. Happy building!