Implementing NFT Gallery on Website
NFT gallery — a page or section showing collection tokens: card grid, filtering by trait attributes, sorting by rarity, detailed token page with history. This is not the same as a user's wallet gallery — it displays collection content wholly, usually accessible without wallet connection.
Data Source
Two approaches: read metadata directly from contract via tokenURI or use NFT API.
Direct reading via tokenURI is slow for 5000+ token collections. One RPC request per token, then IPFS request for JSON. For gallery with trait filtering this is unacceptable.
OpenSea API, Alchemy NFT API, and Reservoir API index metadata and provide it with filtering. Reservoir is the best choice for gallery without payment: free tier 60 req/min, trait filters and rarity support.
Loading Collection via Reservoir API
// lib/collection.ts
const RESERVOIR_BASE = 'https://api.reservoir.tools';
export interface CollectionToken {
tokenId: string;
name: string;
image: string;
rarityScore: number;
rarityRank: number;
attributes: Array<{ key: string; value: string; tokenCount: number }>;
lastSalePrice: string | null;
floorAskPrice: string | null;
}
export async function getCollectionTokens(
contractAddress: string,
opts: {
limit?: number;
offset?: number;
sortBy?: 'floorAskPrice' | 'rarity' | 'tokenId';
attributes?: Record<string, string>;
} = {},
): Promise<{ tokens: CollectionToken[]; total: number }> {
const params = new URLSearchParams({
collection: contractAddress,
limit: String(opts.limit ?? 20),
offset: String(opts.offset ?? 0),
sortBy: opts.sortBy ?? 'tokenId',
includeAttributes: 'true',
includeLastSale: 'true',
});
if (opts.attributes) {
for (const [key, value] of Object.entries(opts.attributes)) {
params.append('attributes[' + key + ']', value);
}
}
const res = await fetch(`${RESERVOIR_BASE}/tokens/v7?${params}`, {
headers: { 'x-api-key': process.env.RESERVOIR_API_KEY ?? '' },
next: { revalidate: 60 },
});
const data = await res.json();
return {
tokens: data.tokens.map(mapToken),
total: data.totalTokens ?? 0,
};
}
Attribute Filters
// Get all trait types and values for collection
export async function getCollectionAttributes(contractAddress: string) {
const res = await fetch(
`${RESERVOIR_BASE}/collections/${contractAddress}/attributes/all/v4`,
{ headers: { 'x-api-key': process.env.RESERVOIR_API_KEY ?? '' } },
);
const data = await res.json();
// Structure: { attributes: [{ key, kind, values: [{ value, count }] }] }
return data.attributes as Array<{
key: string;
kind: 'string' | 'number' | 'range';
values: Array<{ value: string; count: number }>;
}>;
}
Gallery Component with URL Filters
Filters are stored in URL — user can share link to filtered view:
// app/gallery/page.tsx (Next.js App Router)
import { useSearchParams, useRouter } from 'next/navigation';
import { getCollectionTokens, getCollectionAttributes } from '@/lib/collection';
const CONTRACT = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS!;
export default async function GalleryPage({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
const page = parseInt(searchParams.page ?? '1');
const sortBy = (searchParams.sort ?? 'tokenId') as 'floorAskPrice' | 'rarity' | 'tokenId';
// Collect attribute filters from search params
const attributes: Record<string, string> = {};
for (const [key, value] of Object.entries(searchParams)) {
if (!['page', 'sort'].includes(key)) {
attributes[key] = value;
}
}
const [{ tokens, total }, attrs] = await Promise.all([
getCollectionTokens(CONTRACT, {
limit: 24,
offset: (page - 1) * 24,
sortBy,
attributes: Object.keys(attributes).length ? attributes : undefined,
}),
getCollectionAttributes(CONTRACT),
]);
return (
<div className="flex gap-8">
<TraitFilters attributes={attrs} activeFilters={attributes} />
<div className="flex-1">
<SortControl currentSort={sortBy} />
<TokenGrid tokens={tokens} />
<Pagination total={total} page={page} perPage={24} />
</div>
</div>
);
}
Token Card with Rarity
// components/TokenCard.tsx
import Link from 'next/link';
import { CollectionToken } from '@/lib/collection';
function RarityBadge({ rank, total }: { rank: number; total: number }) {
const percentile = (rank / total) * 100;
const tier =
percentile <= 1 ? { label: 'Legendary', color: 'text-yellow-400 bg-yellow-400/10' } :
percentile <= 5 ? { label: 'Epic', color: 'text-purple-400 bg-purple-400/10' } :
percentile <= 15 ? { label: 'Rare', color: 'text-blue-400 bg-blue-400/10' } :
{ label: 'Common', color: 'text-neutral-400 bg-neutral-400/10' };
return (
<span className={`rounded-md px-2 py-0.5 text-xs font-medium ${tier.color}`}>
#{rank} · {tier.label}
</span>
);
}
export function TokenCard({ token, totalSupply }: { token: CollectionToken; totalSupply: number }) {
return (
<Link href={`/gallery/${token.tokenId}`} className="group block">
<div className="overflow-hidden rounded-xl border border-white/5 bg-neutral-900 transition hover:border-white/20">
<div className="relative aspect-square overflow-hidden bg-neutral-800">
<img
src={token.image}
alt={token.name}
loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<span className="font-medium truncate">{token.name}</span>
<RarityBadge rank={token.rarityRank} total={totalSupply} />
</div>
{token.floorAskPrice && (
<p className="text-sm text-neutral-400">
Floor: <span className="text-white">{token.floorAskPrice} ETH</span>
</p>
)}
</div>
</div>
</Link>
);
}
Detailed Token Page
// app/gallery/[tokenId]/page.tsx
export default async function TokenPage({ params }: { params: { tokenId: string } }) {
const token = await getToken(CONTRACT, params.tokenId);
return (
<div className="grid grid-cols-1 gap-12 lg:grid-cols-2">
<TokenImage src={token.image} name={token.name} />
<div className="space-y-6">
<TokenHeader token={token} />
<AttributeGrid attributes={token.attributes} />
<TradeActions token={token} />
<SaleHistory contractAddress={CONTRACT} tokenId={params.tokenId} />
</div>
</div>
);
}
SEO and Static Generation
For collections up to 10000 tokens — static generation with generateStaticParams in Next.js. For larger collections — ISR with revalidate: 3600.
Timeline: grid with basic filters and detailed page — 2–3 days. Full gallery with rarity sorting, multi-level filters, sale history, and SEO optimization — 5–7 days.







