Implementing NFT Collection Display on Website
Displaying user NFTs is not the same as displaying a list of ERC-20 tokens. NFT — metadata on IPFS, images in IPFS or Arweave, JSON OpenSea Metadata Standard schema, and also ERC-721 vs ERC-1155. Solving it yourself through on-chain calls — slow and painful. Using a specialized NFT API — the right approach.
Why Not Read NFT Directly from Contract
ERC-721 contract doesn't store a list of tokens by address. There's ownerOf(tokenId) and tokenURI(tokenId), but no tokensOfOwner(address). To find all user NFTs through RPC, either iterate through Transfer events for entire blockchain history, or use ERC721Enumerable — extension that many collections don't have.
NFT API (Alchemy, Moralis, OpenSea, QuickNode) indexes Transfer events and provides ready endpoint.
Getting NFT via Alchemy NFT API
// lib/nft.ts
import { Alchemy, Network, OwnedNft } from 'alchemy-sdk';
const alchemy = new Alchemy({
apiKey: process.env.ALCHEMY_API_KEY,
network: Network.ETH_MAINNET,
});
export interface NftItem {
tokenId: string;
contractAddress: string;
name: string;
description: string;
imageUrl: string;
collectionName: string;
attributes: Array<{ trait_type: string; value: string | number }>;
}
export async function getWalletNfts(
ownerAddress: string,
opts?: { contractAddresses?: string[]; pageSize?: number },
): Promise<NftItem[]> {
const response = await alchemy.nft.getNftsForOwner(ownerAddress, {
contractAddresses: opts?.contractAddresses,
pageSize: opts?.pageSize ?? 100,
omitMetadata: false,
});
return response.ownedNfts.map(mapNft);
}
function mapNft(nft: OwnedNft): NftItem {
const imageUrl = resolveIpfsUrl(
nft.image?.cachedUrl ?? nft.image?.originalUrl ?? '',
);
return {
tokenId: nft.tokenId,
contractAddress: nft.contract.address,
name: nft.name ?? `#${nft.tokenId}`,
description: nft.description ?? '',
imageUrl,
collectionName: nft.contract.name ?? 'Unknown Collection',
attributes: (nft.raw?.metadata?.attributes ?? []) as NftItem['attributes'],
};
}
function resolveIpfsUrl(url: string): string {
if (url.startsWith('ipfs://')) {
return url.replace('ipfs://', 'https://cloudflare-ipfs.com/ipfs/');
}
return url;
}
Gallery Component
// components/NftGallery.tsx
import { useQuery } from '@tanstack/react-query';
import { getWalletNfts, NftItem } from '@/lib/nft';
interface NftGalleryProps {
address: string;
contractFilter?: string[];
}
export function NftGallery({ address, contractFilter }: NftGalleryProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['nfts', address, contractFilter],
queryFn: () => getWalletNfts(address, { contractAddresses: contractFilter }),
staleTime: 60_000, // NFTs rarely change — cache for a minute
enabled: !!address,
});
if (isLoading) return <NftGridSkeleton count={12} />;
if (error) return <ErrorState message="Failed to load NFTs" />;
if (!data?.length) return <EmptyState />;
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{data.map(nft => (
<NftCard key={`${nft.contractAddress}-${nft.tokenId}`} nft={nft} />
))}
</div>
);
}
NFT Card with Lazy Image Loading
// components/NftCard.tsx
import { useState } from 'react';
import { NftItem } from '@/lib/nft';
export function NftCard({ nft }: { nft: NftItem }) {
const [imgError, setImgError] = useState(false);
return (
<div className="group relative overflow-hidden rounded-xl border border-white/10 bg-neutral-900">
<div className="aspect-square overflow-hidden bg-neutral-800">
{imgError ? (
<div className="flex h-full items-center justify-center text-neutral-500">
<ImageIcon className="h-12 w-12" />
</div>
) : (
<img
src={nft.imageUrl}
alt={nft.name}
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform group-hover:scale-105"
onError={() => setImgError(true)}
/>
)}
</div>
<div className="p-3">
<p className="truncate text-xs text-neutral-400">{nft.collectionName}</p>
<p className="mt-0.5 truncate font-medium text-white">{nft.name}</p>
</div>
</div>
);
}
Detailed View with Attributes
// components/NftDetail.tsx
export function NftDetail({ nft }: { nft: NftItem }) {
return (
<div className="space-y-6">
<img src={nft.imageUrl} alt={nft.name} className="w-full rounded-2xl" />
<div>
<h2 className="text-2xl font-bold">{nft.name}</h2>
<p className="mt-1 text-sm text-neutral-400">
{nft.collectionName} · #{nft.tokenId}
</p>
</div>
{nft.description && (
<p className="text-sm leading-relaxed text-neutral-300">{nft.description}</p>
)}
{nft.attributes.length > 0 && (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-neutral-500">
Attributes
</h3>
<div className="grid grid-cols-3 gap-2">
{nft.attributes.map((attr, i) => (
<div key={i} className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2 text-center">
<p className="text-xs text-blue-400">{attr.trait_type}</p>
<p className="mt-0.5 text-sm font-medium">{attr.value}</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
ERC-1155 Support
ERC-1155 tokens — semi-fungible: one tokenId can belong to multiple addresses with different quantities. Alchemy API returns balance for such tokens:
// For ERC-1155 check quantity
const nft1155Items = response.ownedNfts.filter(
nft => nft.tokenType === 'ERC1155',
).map(nft => ({
...mapNft(nft),
quantity: nft.balance, // "5" — owner holds 5 copies
}));
Pagination for Large Collections
export async function getAllWalletNfts(ownerAddress: string): Promise<NftItem[]> {
const all: NftItem[] = [];
let pageKey: string | undefined;
do {
const response = await alchemy.nft.getNftsForOwner(ownerAddress, {
pageKey,
pageSize: 100,
});
all.push(...response.ownedNfts.map(mapNft));
pageKey = response.pageKey;
} while (pageKey);
return all;
}
Timeline: basic gallery with Alchemy API, lazy loading, and detailed view — 1–2 days. Extended version with collection filters, pagination, ERC-1155 support, and multi-chain — 3–4 days.







