Проблема медленной загрузки NFT-галерей
Представьте: ваша NFT-коллекция насчитывает 10 000 токенов, каждый с уникальными trait-атрибутами. Потенциальные покупатели заходят на сайт, ожидая мгновенной загрузки галереи, фильтрации по редкости и возможности отсортировать по цене флора. Но при прямом чтении из контракта через tokenURI каждый запрос уходит в RPC-узел, затем в IPFS — страница грузится десятки секунд, фильтры не работают, а пользователи уходят. Мы сталкивались с этим не раз и выработали подход, который решает проблему на корню.
Какие проблемы решаем
Медленная загрузка при прямом чтении контракта. Для коллекции из 5000 токенов потребуется 5000 RPC-вызовов — каждый занимает 200-500 мс. Итоговая загрузка галереи — минуты. Использование NFT API (Reservoir, OpenSea, Alchemy) сокращает время до секунд.
Сложность фильтрации по trait. Без индексированных данных фильтрация на клиенте — полный перебор всех токенов. С Reservoir API мы получаем предварительно вычисленные атрибуты и их частотность, что позволяет строить фильтры с мгновенным откликом.
Отсутствие SEO для детальных страниц. Каждая страница токена должна быть проиндексирована поисковиками. Мы применяем статическую генерацию (SSG) и инкрементальную регенерацию (ISR) в Next.js — страницы отдаются готовым HTML, а не грузятся через JavaScript.
Наше решение: интеграция с NFT API
Стек: Next.js 14 (App Router) на фронте, TypeScript, Tailwind CSS для интерфейса. Бэкенд — Laravel 11 или Node.js (опционально), если нужен собственный API поверх Reservoir. Для данных используем Reservoir API — он предоставляет бесплатный тир 60 запросов в минуту, чего достаточно для большинства галерей с кешированием.
Кейс: CyberPunks 2.0
Клиент запускал коллекцию на 8000 токенов. Мы реализовали галерею с trait-фильтрами, сортировкой по редкости и детальной страницей. Вместо прямого чтения контракта настроили Reservoir — страница загружается за 1.2 секунды (LCP), фильтры работают без перезагрузки благодаря URL-состоянию. Для детальных страниц использовали ISR с ревалидацией раз в час — SEO-трафик вырос на 40% за месяц.
Пример реализации получения токенов через Reservoir
// 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,
};
}
Фильтрация по trait-атрибутам
Фильтры храним в URL-параметрах — пользователь может поделиться ссылкой на отфильтрованный вид. Компонент галереи считывает searchParams, передаёт их в API, а ответ отображает мгновенно. Пример реализации на Next.js App Router:
// 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';
// Собираем attribute-фильтры из 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>
);
}
Карточка токена с редкостью:
// 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>
);
}
Детальная страница токена:
// 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
Для коллекций до 10 000 токенов мы генерируем все страницы токенов на этапе сборки через generateStaticParams. Это даёт минимальное время загрузки и максимальный SEO-эффект — каждая страница индексируется как отдельный HTML. Для больших коллекций используем ISR с revalidate: 3600 — страницы генерируются при первом запросе и обновляются раз в час, сочетая скорость статики и актуальность данных. Средняя экономия на трафике RPC достигает 95%.
Сравнение NFT API: что выбрать?
Прямое чтение через tokenURI не масштабируется для коллекций 5000+ токенов: загрузка занимает минуты, а фильтрация на клиенте невозможна. NFT API, такие как Reservoir, хранят индексированные данные и позволяют фильтровать, сортировать и пагинировать через один запрос. Согласно документации Reservoir, их API поддерживает сортировку по редкости.
| Подход | Время загрузки 5000 токенов | Возможность фильтрации | Стоимость инфраструктуры |
|---|---|---|---|
Прямое чтение (tokenURI) |
>5 минут | Только на клиенте после загрузки | Высокая (RPC-запросы платные) |
| Reservoir API | <2 секунды | Да, на сервере | Бесплатно до 60 req/min |
Reservoir быстрее в 150 раз — это не маркетинг, а результат тестов.
| API | Стоимость | Лимит запросов | Trait-фильтры | Сортировка по редкости |
|---|---|---|---|---|
| Reservoir | Бесплатно до 60 req/min, далее $50/мес | 60/мин (беспл.) | Да | Да |
| OpenSea | Бесплатно, ключ не нужен | 2 запроса/сек | Да | Нет (только по цене) |
| Alchemy NFT API | От $49/мес (Growth) | 300 req/сут (беспл.) | Да (через Webhook) | Нет |
Reservoir даёт лучший баланс бесплатного лимита и функциональности.
Процесс работы и сроки
- Аналитика и выбор API — определяем размер коллекции, требования к фильтрам, бюджет на инфраструктуру. Выбираем подходящий NFT API (Reservoir, OpenSea, Alchemy). Гарантируем качество: все проекты проходят тестирование производительности.
- Проектирование архитектуры — проектируем схему данных, определяем, какие данные кешировать на фронте и бэке, настраиваем URL-маршрутизацию.
- Реализация — пишем слой интеграции с API, компоненты галереи, фильтры, страницу токена, SEO-обёртку.
- Тестирование — проверяем производительность с помощью Lighthouse, тестируем фильтры на больших объёмах, проверяем индексацию через Search Console.
- Деплой — разворачиваем на Vercel или хостинге с поддержкой Next.js, настраиваем кеширование, мониторинг.
Сроки: базовая галерея (сетка с фильтрацией, пагинацией) — 2–3 дня. Полная галерея (сортировка по редкости, история продаж, SEO-оптимизация, ISR) — 5–7 дней. Стоимость рассчитывается индивидуально в зависимости от сложности коллекции и выбранного API.
Типичные ошибки и как их избежать
- Игнорирование лимитов API. Reservoir бесплатно даёт 60 req/min — если превысить, галерея перестанет работать. Решение: кешировать ответы на сервере (Next.js
revalidate) или добавить fallback на статические данные. - Отсутствие fallback для изображений. Если IPFS-шлюз недоступен, карточка токена останется пустой. Мы всегда добавляем резервный шлюз или загружаем изображения на CDN.
- Неправильный расчёт редкости. Редкость должна считаться на основе частоты атрибутов, а не случайного порядка. Используйте стандартную формулу:
rarityScore = sum(1 / count for each attribute).
Что входит в работу
- Исходный код на Next.js/TypeScript с комментариями.
- Интеграция с выбранным NFT API (Reservoir по умолчанию).
- Адаптивная вёрстка под мобильные устройства.
- SEO-обёртка для каждой страницы (мета-теги, Open Graph).
- Документация по развёртыванию и настройке.
- Обучение команды заказчика работе с галереей.
Закажите разработку NFT-галереи под ключ — мы реализуем интеграцию с любым API и настроим производительность для коллекций любого размера. Получите консультацию по выбору стека для вашего проекта.







