Реализация отображения NFT-коллекции пользователя на сайте
Показать NFT пользователя — не то же самое, что показать список ERC-20 токенов. NFT — это метаданные на IPFS, изображения в IPFS или Arweave, JSON-схема OpenSea Metadata Standard, и ещё ERC-721 vs ERC-1155. Решать самостоятельно через on-chain вызовы — медленно и больно. Использовать специализированный NFT API — правильный подход.
Почему не читать NFT напрямую из контракта
ERC-721 контракт не хранит список токенов по адресу. Есть ownerOf(tokenId) и tokenURI(tokenId), но нет tokensOfOwner(address). Чтобы найти все NFT пользователя через RPC, нужно либо перебирать события Transfer за всю историю блокчейна, либо использовать ERC721Enumerable — расширение, которое есть далеко не у всех коллекций.
NFT API (Alchemy, Moralis, OpenSea, QuickNode) индексируют Transfer-события и предоставляют готовый endpoint.
Получение NFT через 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;
}
Компонент галереи
// 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, // NFT меняются редко — кэшируем на минуту
enabled: !!address,
});
if (isLoading) return <NftGridSkeleton count={12} />;
if (error) return <ErrorState message="Не удалось загрузить NFT" />;
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 с lazy-загрузкой изображений
// 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>
);
}
Детальный просмотр с атрибутами
// 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">
Атрибуты
</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
ERC-1155 токены — полуфунгибельные: один tokenId может принадлежать нескольким адресам с разным количеством. Alchemy API возвращает balance для таких токенов:
// Для ERC-1155 проверяем количество
const nft1155Items = response.ownedNfts.filter(
nft => nft.tokenType === 'ERC1155',
).map(nft => ({
...mapNft(nft),
quantity: nft.balance, // "5" — владелец держит 5 копий
}));
Пагинация для больших коллекций
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;
}
Срок: базовая галерея с Alchemy API, lazy-загрузкой и детальным просмотром — 1–2 дня. Расширенная версия с фильтрацией по коллекциям, пагинацией, поддержкой ERC-1155 и мультичейн — 3–4 дня.







