NFT & Digital Assets
Building an NFT Marketplace Frontend with React and wagmi
TL;DR
I have built NFT marketplace frontends for three different clients, and the pattern is always the same — wallet connection, metadata display, listing flow, buying flow, activity feed. The stack that delivers every time is React with wagmi and viem for contract interaction, RainbowKit for wallet UI, and TanStack Query for caching. This guide walks through the exact architecture and code patterns I use in production. Not toy examples. Real patterns that handle slow RPCs, missing metadata, failed transactions, and users on mobile phones with spotty connections.
Architecture Overview
Before writing a single component, I map the data flow. An NFT marketplace frontend has three layers, and confusing them is where most projects go wrong.
Layer 1 — Wallet State. Is the user connected? Which chain are they on? What is their address? This is wagmi's domain.
Layer 2 — Contract State. What NFTs exist in the collection? Who owns them? What are the current listings? This comes from on-chain reads (via viem) and an indexer (The Graph or a custom backend).
Layer 3 — UI State. Which filters are active? Is the buy modal open? What step of the listing flow is the user on? This is React state.
The mistake I see constantly is mixing these layers. Developers will store contract data in React state, manually sync wallet changes, or try to use wagmi hooks for UI concerns. Keep the layers clean.
Here is the project structure I use:
src/
config/
wagmi.ts # Chain config, transports, connectors
contracts.ts # ABI + address maps per chain
hooks/
useNFTMetadata.ts # Fetch and cache token metadata
useListings.ts # Active marketplace listings
useActivity.ts # Recent sales, transfers, listings
components/
wallet/ # Connect button, account display
collection/ # Grid, card, filters
trade/ # List modal, buy modal, tx status
activity/ # Event feed, transaction history
lib/
metadata.ts # IPFS gateway resolution, fallbacks
format.ts # ETH formatting, address truncationEvery contract interaction goes through a custom hook. Every hook uses TanStack Query under the hood (wagmi does this automatically). This gives us caching, deduplication, and background refetching for free.
Wallet Connection with RainbowKit
Wallet connection is the front door. If it is broken or confusing, users leave before they see a single NFT. I use RainbowKit because it handles the edge cases I do not want to think about — WalletConnect v2 relay issues, mobile deep linking, chain switching modals, and the dozen wallet providers my users might have installed.
The setup starts in the wagmi config:
// src/config/wagmi.ts
import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { mainnet, arbitrum, base, optimism } from 'wagmi/chains';
import { http } from 'wagmi';
export const config = getDefaultConfig({
appName: 'NFT Marketplace',
projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
chains: [mainnet, base, arbitrum, optimism],
transports: {
[mainnet.id]: http(process.env.NEXT_PUBLIC_RPC_MAINNET),
[base.id]: http(process.env.NEXT_PUBLIC_RPC_BASE),
[arbitrum.id]: http(process.env.NEXT_PUBLIC_RPC_ARBITRUM),
[optimism.id]: http(process.env.NEXT_PUBLIC_RPC_OPTIMISM),
},
});A few things I always do here. First, I never use the default public RPCs in production. They rate-limit aggressively, and your marketplace will feel slow during high-traffic mints. I use Alchemy or Infura endpoints set via environment variables. Second, I include every chain the marketplace contract is deployed on. If you only support mainnet today but plan to launch on Base next quarter, add it now — the config change is trivial, but forgetting it later and debugging "wrong chain" errors costs hours.
The provider wraps the app:
// src/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';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider theme={darkTheme({
accentColor: '#F7931A',
borderRadius: 'medium',
})}>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}I set staleTime to 2 minutes globally. NFT data does not change every second. Without this, wagmi refetches on every mount, every focus, every reconnect — and your RPC bill balloons. For data that changes faster (active auctions, bids), I override at the hook level.
The connect button itself is one line:
import { ConnectButton } from '@rainbow-me/rainbowkit';
// In your header component
<ConnectButton showBalance={false} chainStatus="icon" />I hide the balance because it adds visual noise on a marketplace. Users care about their wallet address and which chain they are on, not their ETH balance on the navbar.
Fetching NFT Metadata
This is where most NFT frontends break. The metadata pipeline has more failure modes than people expect.
An NFT's tokenURI might return a URL pointing to IPFS, Arweave, a centralized API, or even a base64-encoded JSON string. Your frontend needs to handle all of them.
// src/lib/metadata.ts
const IPFS_GATEWAYS = [
'https://cloudflare-ipfs.com/ipfs/',
'https://ipfs.io/ipfs/',
'https://gateway.pinata.cloud/ipfs/',
];
export function resolveUri(uri: string): string {
if (uri.startsWith('ipfs://')) {
return `${IPFS_GATEWAYS[0]}${uri.slice(7)}`;
}
if (uri.startsWith('ar://')) {
return `https://arweave.net/${uri.slice(5)}`;
}
if (uri.startsWith('data:application/json;base64,')) {
return uri; // Handle in the fetch layer
}
return uri;
}
export function resolveImageUri(uri: string): string {
if (!uri) return '/placeholder-nft.png';
return resolveUri(uri);
}The hook that ties it together:
// src/hooks/useNFTMetadata.ts
import { useReadContract } from 'wagmi';
import { useQuery } from '@tanstack/react-query';
import { resolveUri } from '@/lib/metadata';
interface NFTMetadata {
name: string;
description: string;
image: string;
attributes: Array<{
trait_type: string;
value: string | number;
}>;
}
export function useNFTMetadata(
contractAddress: `0x${string}`,
tokenId: bigint
) {
const { data: tokenUri } = useReadContract({
address: contractAddress,
abi: erc721Abi,
functionName: 'tokenURI',
args: [tokenId],
});
return useQuery<NFTMetadata>({
queryKey: ['nft-metadata', contractAddress, tokenId.toString()],
queryFn: async () => {
if (!tokenUri) throw new Error('No tokenURI');
if (tokenUri.startsWith('data:application/json;base64,')) {
const json = atob(tokenUri.split(',')[1]);
return JSON.parse(json);
}
const resolved = resolveUri(tokenUri);
const response = await fetch(resolved, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Metadata fetch failed: ${response.status}`);
}
return response.json();
},
enabled: Boolean(tokenUri),
staleTime: 1000 * 60 * 30, // 30 minutes — metadata rarely changes
retry: 2,
});
}Two critical details here. The AbortSignal.timeout(10_000) prevents a single slow IPFS gateway from blocking the UI forever. And the staleTime of 30 minutes is intentional — NFT metadata is effectively immutable once set. There is no reason to refetch it every 2 minutes.
For on-chain SVG NFTs (where tokenURI returns a base64-encoded data URI), I decode inline. This is actually the fastest path — no external fetch at all.
Displaying Collections
A collection grid needs to feel fast, even when you are showing 10,000 NFTs. The pattern I use is paginated fetching with infinite scroll.
// src/components/collection/NFTCard.tsx
'use client';
import Image from 'next/image';
import { formatEther } from 'viem';
import { useNFTMetadata } from '@/hooks/useNFTMetadata';
import { resolveImageUri } from '@/lib/metadata';
interface NFTCardProps {
contractAddress: `0x${string}`;
tokenId: bigint;
price?: bigint;
owner: `0x${string}`;
}
export function NFTCard({
contractAddress,
tokenId,
price,
owner,
}: NFTCardProps) {
const { data: metadata, isLoading } = useNFTMetadata(
contractAddress,
tokenId
);
if (isLoading) {
return <NFTCardSkeleton />;
}
return (
<article className="group rounded-xl border border-white/10 bg-[#111827] overflow-hidden transition-all duration-200 hover:border-[#F7931A]/40 hover:shadow-lg hover:shadow-[#F7931A]/5">
<div className="relative aspect-square overflow-hidden">
<Image
src={resolveImageUri(metadata?.image ?? '')}
alt={metadata?.name ?? `Token #${tokenId}`}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<div className="p-4 space-y-2">
<h3 className="font-semibold text-white truncate">
{metadata?.name ?? `#${tokenId.toString()}`}
</h3>
<p className="text-sm text-[#6B7FA3] truncate">
{owner.slice(0, 6)}...{owner.slice(-4)}
</p>
{price && (
<p className="text-[#F7931A] font-medium">
{formatEther(price)} ETH
</p>
)}
</div>
</article>
);
}
function NFTCardSkeleton() {
return (
<div className="rounded-xl border border-white/10 bg-[#111827] overflow-hidden animate-pulse">
<div className="aspect-square bg-[#1A2236]" />
<div className="p-4 space-y-2">
<div className="h-5 bg-[#1A2236] rounded w-3/4" />
<div className="h-4 bg-[#1A2236] rounded w-1/2" />
</div>
</div>
);
}The skeleton loader is not optional. Without it, the grid layout jumps as images load at different speeds, causing CLS issues that tank your Core Web Vitals score. The aspect-square container reserves exactly the right space.
For the grid itself, I use CSS Grid with responsive columns:
// src/components/collection/CollectionGrid.tsx
export function CollectionGrid({ tokens }: { tokens: TokenData[] }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{tokens.map((token) => (
<NFTCard
key={`${token.contractAddress}-${token.tokenId}`}
contractAddress={token.contractAddress}
tokenId={token.tokenId}
price={token.price}
owner={token.owner}
/>
))}
</div>
);
}Listing Flow — UX Pattern
Listing an NFT for sale is a multi-step process, and each step can fail independently. The user needs to know exactly where they are and what is happening. I model this as a state machine.
// src/hooks/useListNFT.ts
import { useState } from 'react';
import {
useWriteContract,
useWaitForTransactionReceipt,
useReadContract,
} from 'wagmi';
import { erc721Abi } from 'viem';
import { marketplaceAbi, marketplaceAddress } from '@/config/contracts';
type ListingStep =
| 'idle'
| 'checking-approval'
| 'approving'
| 'waiting-approval'
| 'listing'
| 'waiting-listing'
| 'success'
| 'error';
export function useListNFT(contractAddress: `0x${string}`) {
const [step, setStep] = useState<ListingStep>('idle');
const [error, setError] = useState<string | null>(null);
const { data: approvedAddress } = useReadContract({
address: contractAddress,
abi: erc721Abi,
functionName: 'getApproved',
args: [BigInt(0)], // Updated per token
});
const { writeContractAsync } = useWriteContract();
async function listNFT(tokenId: bigint, priceInWei: bigint) {
try {
setError(null);
// Step 1: Check if marketplace is approved
setStep('checking-approval');
const isApproved = approvedAddress === marketplaceAddress;
if (!isApproved) {
// Step 2: Request approval
setStep('approving');
const approvalHash = await writeContractAsync({
address: contractAddress,
abi: erc721Abi,
functionName: 'approve',
args: [marketplaceAddress, tokenId],
});
setStep('waiting-approval');
// Wait handled by useWaitForTransactionReceipt in the UI
}
// Step 3: Create listing
setStep('listing');
const listingHash = await writeContractAsync({
address: marketplaceAddress,
abi: marketplaceAbi,
functionName: 'createListing',
args: [contractAddress, tokenId, priceInWei],
});
setStep('waiting-listing');
// Transaction confirmation handled in the UI
setStep('success');
return listingHash;
} catch (err) {
setStep('error');
setError(
err instanceof Error ? err.message : 'Transaction failed'
);
throw err;
}
}
return { listNFT, step, error, reset: () => setStep('idle') };
}The UI maps each step to clear feedback:
// src/components/trade/ListingModal.tsx
const STEP_MESSAGES: Record<ListingStep, string> = {
'idle': '',
'checking-approval': 'Checking marketplace approval...',
'approving': 'Approve the marketplace in your wallet...',
'waiting-approval': 'Waiting for approval confirmation...',
'listing': 'Confirm the listing in your wallet...',
'waiting-listing': 'Waiting for listing confirmation...',
'success': 'NFT listed successfully!',
'error': 'Something went wrong.',
};The key insight is that users need to know when they need to act (wallet popup) versus when they need to wait (blockchain confirmation). Conflating these causes users to stare at their screen wondering if the app is frozen, or to accidentally submit duplicate transactions.
I also always show estimated gas before the user clicks "List." Surprising someone with a $15 gas fee after they have already committed mentally to listing is a terrible experience.
Buying Flow — Transaction Confirmation
The buying flow is simpler than listing (one transaction instead of two), but the confirmation UX matters more. When someone is spending money, they need confidence.
// src/hooks/useBuyNFT.ts
import {
useWriteContract,
useWaitForTransactionReceipt,
} from 'wagmi';
import { parseEther } from 'viem';
import { marketplaceAbi, marketplaceAddress } from '@/config/contracts';
export function useBuyNFT() {
const {
writeContract,
data: txHash,
isPending: isConfirming,
error: writeError,
} = useWriteContract();
const {
isLoading: isWaiting,
isSuccess,
error: receiptError,
} = useWaitForTransactionReceipt({ hash: txHash });
function buyNFT(listingId: bigint, price: bigint) {
writeContract({
address: marketplaceAddress,
abi: marketplaceAbi,
functionName: 'buyListing',
args: [listingId],
value: price,
});
}
return {
buyNFT,
isConfirming,
isWaiting,
isSuccess,
txHash,
error: writeError || receiptError,
};
}The transaction confirmation UI follows a pattern I have used across every Web3 project:
// src/components/trade/BuyConfirmation.tsx
export function BuyConfirmation({
isConfirming,
isWaiting,
isSuccess,
txHash,
error,
}: BuyConfirmationProps) {
if (error) {
return (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
<p className="text-red-400 text-sm">
{error.message.includes('user rejected')
? 'Transaction cancelled'
: 'Transaction failed. Please try again.'}
</p>
</div>
);
}
if (isSuccess && txHash) {
return (
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 p-4 space-y-2">
<p className="text-emerald-400 font-medium">
Purchase complete!
</p>
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#4A9EFF] hover:underline"
>
View on Etherscan
</a>
</div>
);
}
if (isConfirming) {
return (
<div className="flex items-center gap-3 p-4">
<Spinner />
<p className="text-[#C9D1E0]">
Confirm the transaction in your wallet...
</p>
</div>
);
}
if (isWaiting) {
return (
<div className="flex items-center gap-3 p-4">
<Spinner />
<p className="text-[#C9D1E0]">
Waiting for blockchain confirmation...
</p>
</div>
);
}
return null;
}Two things I always include. First, the "user rejected" detection. When a user clicks "Reject" in MetaMask, the error message contains "user rejected" — displaying a generic error message for an intentional cancellation is confusing. Second, the Etherscan link on success. Users want proof. Give it to them immediately.
I also disable the buy button while isConfirming || isWaiting is true. Double-buy bugs are real, expensive, and entirely preventable.
Activity Feed — Real-Time Events
An activity feed showing recent listings, sales, and transfers makes the marketplace feel alive. I pull this from on-chain events using wagmi's useWatchContractEvent for real-time updates and a historical query for the initial load.
// src/hooks/useActivity.ts
import { useWatchContractEvent } from 'wagmi';
import { useState, useCallback } from 'react';
import { formatEther } from 'viem';
import { marketplaceAbi, marketplaceAddress } from '@/config/contracts';
interface ActivityEvent {
type: 'listed' | 'sold' | 'cancelled';
tokenId: string;
price: string;
from: string;
to?: string;
timestamp: number;
txHash: string;
}
export function useActivity() {
const [events, setEvents] = useState<ActivityEvent[]>([]);
const addEvent = useCallback((event: ActivityEvent) => {
setEvents((prev) => [event, ...prev].slice(0, 50));
}, []);
useWatchContractEvent({
address: marketplaceAddress,
abi: marketplaceAbi,
eventName: 'ItemListed',
onLogs(logs) {
for (const log of logs) {
addEvent({
type: 'listed',
tokenId: log.args.tokenId!.toString(),
price: formatEther(log.args.price!),
from: log.args.seller!,
timestamp: Date.now(),
txHash: log.transactionHash,
});
}
},
});
useWatchContractEvent({
address: marketplaceAddress,
abi: marketplaceAbi,
eventName: 'ItemSold',
onLogs(logs) {
for (const log of logs) {
addEvent({
type: 'sold',
tokenId: log.args.tokenId!.toString(),
price: formatEther(log.args.price!),
from: log.args.seller!,
to: log.args.buyer!,
timestamp: Date.now(),
txHash: log.transactionHash,
});
}
},
});
return { events };
}For production, I back this with a subgraph or backend indexer. Watching events directly works for real-time updates, but historical data requires querying past blocks, which is slow and costs RPC credits. The pattern I use is: load history from the indexer on mount, then layer real-time events on top via useWatchContractEvent.
Search and Filtering
Search and filtering need to happen client-side for small collections (under 5,000 items) and server-side for anything larger. Here is my client-side filtering pattern:
// src/hooks/useCollectionFilters.ts
import { useMemo, useState } from 'react';
interface FilterState {
search: string;
traits: Record<string, string[]>;
priceMin: string;
priceMax: string;
sortBy: 'price-asc' | 'price-desc' | 'recent' | 'token-id';
status: 'all' | 'listed' | 'unlisted';
}
export function useCollectionFilters(tokens: TokenData[]) {
const [filters, setFilters] = useState<FilterState>({
search: '',
traits: {},
priceMin: '',
priceMax: '',
sortBy: 'recent',
status: 'all',
});
const filtered = useMemo(() => {
let result = [...tokens];
// Text search — name or token ID
if (filters.search) {
const query = filters.search.toLowerCase();
result = result.filter(
(t) =>
t.name?.toLowerCase().includes(query) ||
t.tokenId.toString().includes(query)
);
}
// Status filter
if (filters.status === 'listed') {
result = result.filter((t) => t.price !== undefined);
} else if (filters.status === 'unlisted') {
result = result.filter((t) => t.price === undefined);
}
// Trait filters
for (const [traitType, values] of Object.entries(filters.traits)) {
if (values.length > 0) {
result = result.filter((t) =>
t.attributes?.some(
(attr) =>
attr.trait_type === traitType &&
values.includes(String(attr.value))
)
);
}
}
// Price range
if (filters.priceMin) {
const min = BigInt(filters.priceMin);
result = result.filter((t) => t.price && t.price >= min);
}
if (filters.priceMax) {
const max = BigInt(filters.priceMax);
result = result.filter((t) => t.price && t.price <= max);
}
// Sort
result.sort((a, b) => {
switch (filters.sortBy) {
case 'price-asc':
return Number((a.price ?? 0n) - (b.price ?? 0n));
case 'price-desc':
return Number((b.price ?? 0n) - (a.price ?? 0n));
case 'token-id':
return Number(a.tokenId - b.tokenId);
case 'recent':
default:
return (b.listedAt ?? 0) - (a.listedAt ?? 0);
}
});
return result;
}, [tokens, filters]);
return { filters, setFilters, filtered };
}The trait filter is the most complex part. I extract available traits from the collection metadata and render them as collapsible checkbox groups in the sidebar. The key UX detail: show the count of items matching each trait value. "Background: Blue (342)" tells the user whether a filter will show results or zero them out.
For larger collections, I push filtering to a Supabase backend or Algolia. The frontend sends filter params, the backend returns paginated results. The hook interface stays identical — the component does not care where the filtering happens.
Mobile Responsiveness
Over 60% of NFT marketplace traffic comes from mobile. If your marketplace does not work on a phone, you have lost the majority of your users before they even connect a wallet.
The patterns that matter:
// Responsive grid — 2 columns on mobile, scaling up
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 sm:gap-4">
// Bottom sheet for filters on mobile instead of sidebar
<Sheet>
<SheetTrigger asChild>
<button className="lg:hidden fixed bottom-4 right-4 z-50 rounded-full bg-[#F7931A] p-4 shadow-lg">
<FilterIcon className="h-5 w-5 text-white" />
</button>
</SheetTrigger>
<SheetContent side="bottom" className="h-[80vh] bg-[#0A0E1A]">
<FilterSidebar filters={filters} onChange={setFilters} />
</SheetContent>
</Sheet>
// Touch-friendly buy button — 48px minimum height
<button className="w-full h-12 rounded-lg bg-[#F7931A] text-white font-semibold text-base hover:bg-[#E07B0A] active:scale-[0.98] transition-all">
Buy Now — {formatEther(price)} ETH
</button>Three mobile-specific patterns I always implement:
- Bottom sheet for filters. A sidebar that slides in from the left does not work on mobile. A bottom sheet that the user can swipe up to reveal works with the thumb zone.
- Sticky buy bar. When viewing an NFT detail page on mobile, the price and buy button stick to the bottom of the viewport. The user should never have to scroll back up to find the action button.
- Wallet connection on mobile. RainbowKit handles this, but I test it thoroughly. On mobile, "Connect Wallet" opens the user's wallet app via deep link, the user approves, and the browser returns with the connection established. This flow breaks if your WalletConnect
projectIdis invalid or if you are testing on localhost without HTTPS.
Performance for Large Collections
A 10,000-item collection will destroy your frontend if you render all items at once. Here is how I keep things fast.
Virtualized rendering. I use @tanstack/react-virtual to render only the visible cards plus a buffer:
// src/components/collection/VirtualizedGrid.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
export function VirtualizedGrid({ tokens }: { tokens: TokenData[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const columns = useColumns(); // Returns 2-5 based on viewport
const rowCount = Math.ceil(tokens.length / columns);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 320,
overscan: 3,
});
return (
<div ref={parentRef} className="h-[80vh] overflow-auto">
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * columns;
const rowTokens = tokens.slice(
startIndex,
startIndex + columns
);
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full grid gap-4"
style={{
top: `${virtualRow.start}px`,
height: `${virtualRow.size}px`,
gridTemplateColumns: `repeat(${columns}, 1fr)`,
}}
>
{rowTokens.map((token) => (
<NFTCard key={token.tokenId.toString()} {...token} />
))}
</div>
);
})}
</div>
</div>
);
}Image optimization. Every NFT image goes through next/image with explicit sizes. For a 5-column grid, each image is roughly 20% of the viewport — loading a 2000x2000px image for a 300px card is a waste of bandwidth. I also set loading="lazy" for images below the fold (the virtualizer handles most of this, but belt and suspenders).
Metadata caching. The 30-minute staleTime on metadata queries means that scrolling through the collection does not re-fetch metadata for items the user already scrolled past. TanStack Query keeps them in the garbage collection window, and when the user scrolls back, the data is instant.
Batch RPC calls. Instead of calling tokenURI one at a time for each visible NFT, I use viem's multicall to batch them:
import { multicall } from '@wagmi/core';
import { erc721Abi } from 'viem';
import { config } from '@/config/wagmi';
async function batchFetchTokenURIs(
contractAddress: `0x${string}`,
tokenIds: bigint[]
) {
const results = await multicall(config, {
contracts: tokenIds.map((id) => ({
address: contractAddress,
abi: erc721Abi,
functionName: 'tokenURI',
args: [id],
})),
});
return results.map((r, i) => ({
tokenId: tokenIds[i],
uri: r.status === 'success' ? (r.result as string) : null,
}));
}One multicall replacing 20 individual RPC calls. This alone can cut page load times in half for collection pages.
Key Takeaways
After building multiple NFT marketplace frontends, these are the patterns that separate production code from tutorial code:
- Separate wallet state, contract state, and UI state. Mixing them creates bugs that are almost impossible to trace. wagmi manages the first two. React manages the third.
- Handle metadata failures gracefully. IPFS gateways go down. Arweave takes 30 seconds. Centralized APIs return 500s. Your UI needs fallbacks, timeouts, and retries — not a blank screen.
- Model multi-step transactions as state machines. Listing requires approval then listing. Buying requires confirmation then receipt. Each step can fail independently. The user needs to know what is happening at every moment.
- Detect "user rejected" errors. It is the most common transaction "error" and it is not an error at all. Handle it differently from actual failures.
- Virtualize large collections. 10,000 DOM nodes will freeze any browser. Render what is visible, cache what was visible, and lazy-load everything else.
- Batch your RPC calls.
multicallexists for a reason. One request that returns 50 results beats 50 individual requests every time.
- Test on mobile with a real wallet. The WalletConnect deep link flow, the bottom sheet filters, the sticky buy bar — these only work if you test them on an actual phone with an actual wallet app installed.
- Cache aggressively. NFT metadata is essentially immutable. Set your
staleTimeaccordingly. Your RPC provider will thank you, and your users will see faster load times.
The NFT marketplace space is competitive, but the technical bar for a good frontend experience is not as high as people think. wagmi and viem handle the hard Web3 parts. TanStack Query handles the caching. RainbowKit handles the wallet UI. Your job is to stitch them together with clean architecture and thoughtful UX.
If you are building an NFT platform and need production-grade frontend engineering, check out my services — I have shipped these patterns for clients across Ethereum, Base, and Arbitrum.
*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK, building production blockchain applications at iamuvin.com↗. Follow @IAMUVIN↗ for Web3 development insights and build logs.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.