An NFT Explorer is a Decentralized Application (dApp) that allows users to get information about an NFT collection from the blockchain, such as the name, owner address, and tokenId.
In this tutorial, we will build an explorer on the Ethereum blockchain using Alchemy and NextJS. Firstly, what is Alchemy?
With the Alchemy API and its support for NFTs on the Ethereum blockchain, querying blockchain data is now easier than ever.
Demo
The video below shows the demo of the finished NFT Explorer we are going to build:
Prerequisites
This tutorial uses the following technologies:
- NextJS
- Tailwind CSS
- Alchemy API
It is also worth noting that for this tutorial, you will need to have:
- A basic understanding of React/NextJS
- A basic knowledge of Tailwind CSS
- VS Code
Step 1 – Create a NextJS App
In this step, we will create a new NextJS application using the npx package manager.
Enter the command below into your terminal to initiate the creation of a new project called nft-explorer:
npx create-next-app@latest nft-explorer
Once you’ve accepted the installation, we will be prompted to configure our project. Press the Enter key on each prompt to accept the default options.
Next, navigate into the project folder with the cd command:
cd nft-explorer
Finally, open your project in a new VS Code window using the command:
code .
Step 2 – Create an Alchemy App
Here, we will create an Alchemy app to obtain our API key.
First, navigate to Alchemy to Sign up.
In the subsequent steps, follow the instructions and enter the necessary details.
After successful registration, you’ll be redirected to your Alchemy Dashboard.
- On your dashboard, click on the “Apps” and “create new app” button:
- Name your Alchemy app (nft-explorer), write a description and select “NFTs” as the “Use case”.
- Zoom out in case you don’t see the “next” button and click on “Next”
- Select the Ethereum Chain.
- Zoom out in case you don’t see the “next” button, and click on “Next”
- Zoom out in case you don’t see the “create app” button, and click on it to finish the setup.
Step 3 – Alchemy App Details
After creating your app, you can view your app details. Take note of your “API Key” and select a network (Mainnet).
Step 4 – Install the Alchemy SDK
In your terminal, install the Alchemy Javascript SDK using the command:
npm install alchemy-sdk
Step 5 – Create your backend API route to fetch NFTs
To continue, we would need to establish a connection between our NextJS and Alchemy applications.
Create a .env file in the root directory of your project and store your Alchemy API key:
ALCHEMY_API_KEY=your_alchemy_api_key
Replace the placeholder with your API key.
Using the structure below, create a route.ts file to create an API endpoint:
src/
├─ app/
│ ├─ api/
│ │ ├─ getnfts/
│ │ │ ├─ route.ts
In the route.ts file, we will define the backend logic to fetch NFTs. We will create an asynchronous function to handle incoming HTTP GET requests to our API route:
import { NextRequest, NextResponse } from "next/server";
import { Alchemy, Network } from "alchemy-sdk";
const config = {
apiKey: process.env.ALCHEMY_API_KEY,
network: Network.ETH_MAINNET,
maxRetries: 3,
requestTimeout: 30000, // 30 seconds
};
const alchemy = new Alchemy(config);
// Helper function to validate wallet address
function isValidWalletAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
// Helper function for retry logic
async function retryWithDelay<T>(
fn: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<T> {
try {
return await fn();
} catch (error) {
if (retries <= 0) throw error;
console.log(`Retrying in ${delay}ms... (${retries} retries left)`);
await new Promise(resolve => setTimeout(resolve, delay));
return retryWithDelay(fn, retries - 1, delay * 2);
}
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const wallet = searchParams.get("wallet");
if (!wallet) {
return NextResponse.json(
{ error: "Wallet address is required" },
{ status: 400 }
);
}
if (!isValidWalletAddress(wallet)) {
return NextResponse.json(
{ error: "Invalid wallet address format" },
{ status: 400 }
);
}
if (!process.env.ALCHEMY_API_KEY) {
console.error("ALCHEMY_API_KEY is not configured");
return NextResponse.json(
{ error: "API configuration error" },
{ status: 500 }
);
}
try {
console.log(`Fetching NFTs for wallet: ${wallet}`);
const results = await retryWithDelay(
() => alchemy.nft.getNftsForOwner(wallet, {
excludeFilters: [], // Optional: exclude spam/airdrops
includeFilters: [],
}),
3,
1000
);
console.log(`Successfully fetched ${results.ownedNfts.length} NFTs`);
return NextResponse.json({
message: "success",
data: results,
count: results.ownedNfts.length
});
} catch (error: any) {
console.error("Alchemy error:", error);
if (error.message?.includes("401") || error.message?.includes("authenticated")) {
return NextResponse.json(
{ error: "API authentication failed. Please check your API key." },
{ status: 401 }
);
}
if (error.code === 'ETIMEDOUT' || error.message?.includes("timeout")) {
return NextResponse.json(
{ error: "Request timeout. The server took too long to respond." },
{ status: 408 }
);
}
if (error.message?.includes("rate limit")) {
return NextResponse.json(
{ error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ error: "Failed to fetch NFTs. Please try again later." },
{ status: 500 }
);
}
}
In the code above:
- The config object holds our API key and the network we will interact with, in this case, the Ethereum Mainnet.
- The alchemy constant creates an instance of the Alchemy SDK using the config object.
- The isValidWalletAdddress regex check ensures any wallet query parameter looks like an 0x‑prefixed, 40 character hexadecimal string (an Ethereum address).
- The retryWithDelay() helper function retries the API call with an exponential backoff before finally showing errors.
- The GET function:
- Reads the value of wallet from the URL and returns error code 400 if missing.
- Checks that the ALCHEMY_API_KEY env is set, otherwise, returns a 500 error.
Step 6 – Create the Components
In this section, we will create the components for our NextJS app.
In our terminal, we’ll run the following command to start our application’s server:
npm run dev
First, we will update the page.tsx file, which will be our main page. Copy and paste the code below into your page.tsx file.
"use client";
export default function Home() {
return (
<div className="h-full mt-20 p-5">
<div className="flex flex-col gap-10">
<div className="flex items-center justify-center">
<h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1>
</div>
<div className="flex space-x-5 items-center justify-center">
<input
type="text"
placeholder="Enter your wallet address"
className="px-5 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<button className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer">
Get NFTs
</button>
</div>
</div>
</div>
);
}
At this point, our main page should look like this:
Building the NFT Cards
The NFT Cards will display the NFTs and other metadata received from our route.tsx file.
To build the NFT Card component, create a components folder in your app folder, then create a new NFTCard.tsx file.
At this point, this is what our file structure should look like:
src/
└── app/
├── api/
│ └── getnfts/
│ └── route.ts
├── components/
│ └── NFTCard.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
└── page.tsx
Afterwards, copy and paste the code below:
import { useEffect, useState } from "react";
import Image from "next/image";
const IPFS_URL = "ipfs://";
const IPFS_GATEWAY_URL = "
https://ipfs.io/ipfs/";
interface ImageData {
originalUrl?: string;
cachedUrl?: string;
}
interface ContractData {
address?: string;
}
interface Metadata {
image?: string;
}
interface Data {
image?: ImageData;
tokenUri?: string | { raw?: string };
contract?: ContractData;
tokenId: string;
name?: string;
}
interface NFTCardProps {
data: Data;
}
export default function NFTCard({ data }: NFTCardProps) {
const [imageUrl, setImageUrl] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const resolveImageUrl = async () => {
let rawUrl = data?.image?.originalUrl || data?.image?.cachedUrl;
if (!rawUrl) {
let tokenUri =
typeof data?.tokenUri === "string"
? data.tokenUri
: data?.tokenUri?.raw;
if (tokenUri?.startsWith(IPFS_URL)) {
tokenUri = tokenUri.replace(IPFS_URL, IPFS_GATEWAY_URL);
}
try {
const res = await fetch(tokenUri);
const metadata: Metadata = await res.json();
rawUrl = metadata?.image;
} catch (err) {
console.error("Failed to load metadata:", err);
}
}
if (!rawUrl) return;
const finalUrl = rawUrl.startsWith(IPFS_URL)
? rawUrl.replace(IPFS_URL, IPFS_GATEWAY_URL)
: rawUrl;
setImageUrl(finalUrl);
};
resolveImageUrl();
}, [data]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(data.contract?.address || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const shortAddress = data.contract?.address
? data.contract.address.slice(0, 20) + "..."
: null;
const shortTokenId =
data.tokenId.length > 20 ? data.tokenId.slice(0, 20) + "..." : data.tokenId;
return (
<div className="p-5 border rounded-lg flex flex-col">
{imageUrl ? (
<Image
src={imageUrl}
alt={data.name || "NFT Image"}
width={500}
height={500}
unoptimized
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center text-gray-500">
Loading...
</div>
)}
<div className="mt-2">{data.name || <i>No name provided</i>}</div>
<div
className="mt-2 cursor-pointer hover:underline relative"
title={data.contract?.address}
onClick={handleCopy}
>
{copied ? "Copied!" : shortAddress || <i>No contract address</i>}
</div>
<div className="mt-2" title={data.tokenId}>
Token ID: {shortTokenId}
</div>
</div>
);
}
The NFTCard component will receive the data prop. The data prop will contain the NFT’s metadata (image, name, token ID, and contract address).
The imageUrl state holds the final image URL to display the NFT, and copied state tracks if the contract address was recently copied to the clipboard.
The resolveImageUrl() function first tries to use data.image.originalUrl or data.image.cachedUrl as the NFT image. If those are missing, it fetches the metadata from data.tokenUri, replacing any ipfs:// URLs with a browser-friendly https://ipfs.io/ipfs/ format. It then extracts the image field from the metadata and sets it as the final imageUrl to display.
The handleCopy function copies the contract address to the user’s clipboard and sets copied to true.
Building the Modal Component
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
type?: "error" | "success" | "warning" | "info";
}
export default function Modal({
isOpen,
onClose,
title,
children,
type = "error"
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
// Handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
// Prevent body scroll when modal is open
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, onClose]);
// Focus management
useEffect(() => {
if (isOpen && modalRef.current) {
modalRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
const getIconAndColors = () => {
switch (type) {
case "error":
return {
icon: "❌",
bgColor: "bg-red-50",
borderColor: "border-red-200",
iconBg: "bg-red-100",
titleColor: "text-red-800",
textColor: "text-red-700"
};
case "success":
return {
icon: "✅",
bgColor: "bg-green-50",
borderColor: "border-green-200",
iconBg: "bg-green-100",
titleColor: "text-green-800",
textColor: "text-green-700"
};
case "warning":
return {
icon: "⚠️",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
iconBg: "bg-yellow-100",
titleColor: "text-yellow-800",
textColor: "text-yellow-700"
};
default:
return {
icon: "ℹ️",
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
iconBg: "bg-blue-100",
titleColor: "text-blue-800",
textColor: "text-blue-700"
};
}
};
const { icon, bgColor, borderColor, iconBg, titleColor, textColor } = getIconAndColors();
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={onClose}
></div>
{/* Modal */}
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
ref={modalRef}
tabIndex={-1}
className={`relative transform overflow-hidden rounded-lg ${bgColor} ${borderColor} border-2 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6`}
>
<div>
<div className={`mx-auto flex h-12 w-12 items-center justify-center rounded-full ${iconBg}`}>
<span className="text-2xl">{icon}</span>
</div>
<div className="mt-3 text-center sm:mt-5">
<h3
className={`text-lg font-medium leading-6 ${titleColor}`}
id="modal-title"
>
{title}
</h3>
<div className={`mt-2 ${textColor}`}>
{children}
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-gray-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
onClick={onClose}
>
Close
</button>
</div>
</div>
</div>
</div>
);
}
The Modal component displays overlay dialogs for our UI by receiving props that control its behavior and appearance.
The isOpen and onClose boolean parameters show/hide and close the modal respectively. The children parameter represents the content to display inside the modal.
Lastly, the optional type parameter which defaults to error specifies the type of modal to display.
Step 7 – Update the Components
Import the NFTCard.tsx and Modal.tsx component into the page.tsx file:
import { useState } from "react";
import NFTCard from "./components/NFTCard";
import Modal from "./components/Modal";
Just below the new components import, copy and paste the code below into your page.tsx file:
interface ImageData {
originalUrl?: string;
cachedUrl?: string;
}
interface ContractData {
address: string;
}
interface NFTData {
image?: ImageData;
tokenUri?: string | { raw?: string };
contract: ContractData;
tokenId: string;
name?: string;
}
interface ApiResponse {
data: {
ownedNfts: NFTData[];
};
}
interface ApiError {
error: string;
}
interface ModalState {
isOpen: boolean;
title: string;
message: string;
type: "error" | "success" | "warning" | "info";
}
In the update above, we introduce interfaces to define the objects’ data-type.
- The ImageData Interface sets the structure of an NFT image to have optional originalUrl and cachedUrl fields, whereas the ContractData interface has one required field.
- The NFTData Interface defines the information of a single NFT, and the ApiResponse denotes a successful API NFT call’s structure.
- The ApiError and ModalState Interfaces define the API error responses and the structure of the Modal component respectively.
In the next step, we’ll add state variables and define functions to manage our UI.
Add the code below to your page.tsx component.
const [address, setAddress] = useState<string>("");
const [data, setData] = useState<NFTData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [hasSearched, setHasSearched] = useState<boolean>(false);
const [modal, setModal] = useState<ModalState>({
isOpen: false,
title: "",
message: "",
type: "error",
});
const showModal = (
title: string,
message: string,
type: ModalState["type"] = "error"
) => {
setModal({
isOpen: true,
title,
message,
type,
});
};
const closeModal = () => {
setModal((prev) => ({ ...prev, isOpen: false }));
};
const getNfts = async (): Promise<void> => {
if (!address.trim()) {
showModal(
"Invalid Input",
"Please enter a wallet address before searching.",
"warning"
);
return;
}
setLoading(true);
setHasSearched(true);
try {
const response = await fetch(`./api/getnfts?wallet=${address}`);
if (!response.ok) {
try {
const errorData: ApiError = await response.json();
const errorMessage =
errorData.error || `HTTP error! status: ${response.status}`;
switch (response.status) {
case 400:
showModal(
"Invalid Request",
errorMessage ||
"The wallet address format is invalid. Please check and try again."
);
break;
case 401:
showModal(
"Authentication Error",
errorMessage ||
"API authentication failed. Please contact support."
);
break;
case 408:
showModal(
"Request Timeout",
errorMessage ||
"The request took too long to complete. Please try again."
);
break;
case 429:
showModal(
"Rate Limit Exceeded",
errorMessage ||
"Too many requests. Please wait a moment and try again."
);
break;
case 500:
showModal(
"Server Error",
errorMessage || "Internal server error. Please try again later."
);
break;
default:
showModal(
"Request Failed",
errorMessage ||
`Unexpected error occurred (${response.status}). Please try again.`
);
}
} catch {
showModal(
"Network Error",
`Failed to fetch NFTs. Server responded with status ${response.status}.`
);
}
setData([]);
return;
}
const responseData: ApiResponse = await response.json();
console.log(responseData);
setData(responseData.data.ownedNfts);
} catch (error) {
console.error("Error fetching NFTs:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
showModal(
"Connection Error",
"Unable to connect to the server. Please check your internet connection and try again."
);
} else {
showModal(
"Unexpected Error",
"An unexpected error occurred while fetching NFTs. Please try again."
);
}
setData([]);
} finally {
setLoading(false);
}
};
const handleAddressChange = (
e: React.ChangeEvent<HTMLInputElement>
): void => {
setAddress(e.target.value);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
getNfts();
}
};
const EmptyState = () => (
<div className="flex flex-col items-center justify-center py-20 px-5">
<div className="text-6xl mb-6">🖼️</div>
<h2 className="text-2xl font-semibold text-gray-600 mb-3">
No NFTs Found
</h2>
<p className="text-gray-500 text-center max-w-md mb-6">
We couldn't find any NFTs for this wallet address. This could mean:
</p>
<ul className="text-gray-500 text-sm space-y-2 mb-8">
<li>• The wallet doesn't own any NFTs</li>
<li>• The address might be incorrect</li>
<li>• The NFTs might not be indexed yet</li>
</ul>
<button
onClick={() => {
setAddress("");
setHasSearched(false);
setData([]);
}}
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all"
>
Try Another Address
</button>
</div>
);
const LoadingState = () => (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mb-4"></div>
<p className="text-gray-600">Loading NFTs...</p>
</div>
);
Checking back, our app still looks great, we’ve not broken anything!
In the next step, we have to set the value of the input tag to the wallet address. Update the input tag by pasting this code:
value={address}
onChange={handleAddressChange}
onKeyDown={handleKeyPress}
disabled={loading}
Update the button tag:
onClick={getNfts}
disabled={loading}
In the button tag, add the loading variable, which disables the button and and shows the loading message while fetching the NFTs.
<button
className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer"
onClick={getNfts}
disabled={loading}
>
{loading ? "Loading..." : "Get NFTs"}
</button>
Lastly, update our content area, by adding the NFTCard and Modal components:
{/* Content Area */}
{loading ? (
<LoadingState />
) : hasSearched && data.length === 0 ? (
<EmptyState />
) : data.length > 0 ? (
<>
<div className="text-center text-gray-600">
Found {data.length} NFT{data.length !== 1 ? 's' : ''}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5">
{data.map((nft: NFTData) => (
<NFTCard
key={`${nft.contract.address}-${nft.tokenId}`}
data={nft}
/>
))}
</div>
</>
) : (
<div className="text-center text-gray-500 py-20">
Enter a wallet address above to explore NFTs
</div>
)}
</div>
{/* Modal */}
<Modal
isOpen={modal.isOpen}
onClose={closeModal}
title={modal.title}
type={modal.type}
>
<p className="text-sm">{modal.message}</p>
</Modal>
</div>
Final Look
This is what our page.tsx should look like:
"use client";
import { useState } from "react";
import NFTCard from "./components/NFTCard";
import NFTCard from "./components/NFTCard";import NFTCard from "./components/NFTCard";
import Modal from "./components/Modal";
interface ImageData {
originalUrl?: string;
cachedUrl?: string;
}
interface ContractData {
address: string;
}
interface NFTData {
image?: ImageData;
tokenUri?: string | { raw?: string };
contract: ContractData;
tokenId: string;
name?: string;
}
interface ApiResponse {
data: {
ownedNfts: NFTData[];
};
}
interface ApiError {
error: string;
}
interface ModalState {
isOpen: boolean;
title: string;
message: string;
type: "error" | "success" | "warning" | "info";
}
export default function Home() {
const [address, setAddress] = useState<string>("");
const [data, setData] = useState<NFTData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [hasSearched, setHasSearched] = useState<boolean>(false);
const [modal, setModal] = useState<ModalState>({
isOpen: false,
title: "",
message: "",
type: "error",
});
const showModal = (
title: string,
message: string,
type: ModalState["type"] = "error"
) => {
setModal({
isOpen: true,
title,
message,
type,
});
};
const closeModal = () => {
setModal((prev) => ({ ...prev, isOpen: false }));
};
const getNfts = async (): Promise<void> => {
if (!address.trim()) {
showModal(
"Invalid Input",
"Please enter a wallet address before searching.",
"warning"
);
return;
}
setLoading(true);
setHasSearched(true);
try {
const response = await fetch(`./api/getnfts?wallet=${address}`);
if (!response.ok) {
try {
const errorData: ApiError = await response.json();
const errorMessage =
errorData.error || `HTTP error! status: ${response.status}`;
switch (response.status) {
case 400:
showModal(
"Invalid Request",
errorMessage ||
"The wallet address format is invalid. Please check and try again."
);
break;
case 401:
showModal(
"Authentication Error",
errorMessage ||
"API authentication failed. Please contact support."
);
break;
case 408:
showModal(
"Request Timeout",
errorMessage ||
"The request took too long to complete. Please try again."
);
break;
case 429:
showModal(
"Rate Limit Exceeded",
errorMessage ||
"Too many requests. Please wait a moment and try again."
);
break;
case 500:
showModal(
"Server Error",
errorMessage || "Internal server error. Please try again later."
);
break;
default:
showModal(
"Request Failed",
errorMessage ||
`Unexpected error occurred (${response.status}). Please try again.`
);
}
} catch {
showModal(
"Network Error",
`Failed to fetch NFTs. Server responded with status ${response.status}.`
);
}
setData([]);
return;
}
const responseData: ApiResponse = await response.json();
console.log(responseData);
setData(responseData.data.ownedNfts);
} catch (error) {
console.error("Error fetching NFTs:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
showModal(
"Connection Error",
"Unable to connect to the server. Please check your internet connection and try again."
);
} else {
showModal(
"Unexpected Error",
"An unexpected error occurred while fetching NFTs. Please try again."
);
}
setData([]);
} finally {
setLoading(false);
}
};
const handleAddressChange = (
e: React.ChangeEvent<HTMLInputElement>
): void => {
setAddress(e.target.value);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
getNfts();
}
};
const EmptyState = () => (
<div className="flex flex-col items-center justify-center py-20 px-5">
<div className="text-6xl mb-6">🖼️</div>
<h2 className="text-2xl font-semibold text-gray-600 mb-3">
No NFTs Found
</h2>
<p className="text-gray-500 text-center max-w-md mb-6">
We couldn't find any NFTs for this wallet address. This could mean:
</p>
<ul className="text-gray-500 text-sm space-y-2 mb-8">
<li>• The wallet doesn't own any NFTs</li>
<li>• The address might be incorrect</li>
<li>• The NFTs might not be indexed yet</li>
</ul>
<button
onClick={() => {
setAddress("");
setHasSearched(false);
setData([]);
}}
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all"
>
Try Another Address
</button>
</div>
);
const LoadingState = () => (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mb-4"></div>
<p className="text-gray-600">Loading NFTs...</p>
</div>
);
return (
<div className="h-full mt-20 p-5">
<div className="flex flex-col gap-10">
<div className="flex items-center justify-center">
<h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1>
</div>
<div className="flex space-x-5 items-center justify-center">
<input
type="text"
placeholder="Enter your wallet address"
className="px-5 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
value={address}
onChange={handleAddressChange}
onKeyDown={handleKeyPress}
disabled={loading}
/>
<button
className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={getNfts}
disabled={loading}
>
{loading ? "Loading..." : "Get NFTs"}
</button>
</div>
{/* Content Area */}
{loading ? (
<LoadingState />
) : hasSearched && data.length === 0 ? (
<EmptyState />
) : data.length > 0 ? (
<>
<div className="text-center text-gray-600">
Found {data.length} NFT{data.length !== 1 ? "s" : ""}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5">
{data.map((nft: NFTData) => (
<NFTCard
key={`${nft.contract.address}-${nft.tokenId}`}
data={nft}
/>
))}
</div>
</>
) : (
<div className="text-center text-gray-500 py-20">
Enter a wallet address above to explore NFTs
</div>
)}
</div>
{/* Modal */}
<Modal
isOpen={modal.isOpen}
onClose={closeModal}
title={modal.title}
type={modal.type}
>
<p className="text-sm">{modal.message}</p>
</Modal>
</div>
);
}
Step 8 – Reconfigure the Next.config.mjs file
Now that we have built the User Interface, we will have to reconfigure the next.config.mjs file. It is located in the root directory of our project.
Copy and paste this file into your next.config.mjs file:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "ipfs.io",
},
{
protocol: "https",
hostname: "nft-cdn.alchemy.com",
},
{
protocol: "https",
hostname: "res.cloudinary.com",
},
{
protocol: "https",
hostname: "i.seadn.io",
},
{
protocol: "https",
hostname: "www.troublemaker.fun",
},
{
protocol: "https",
hostname: "y.at",
},
{
protocol: "https",
hostname: "**",
},
],
},
};
export default nextConfig;
In the code above, we are instructing NextJS to display images from these websites. This step is very crucial as NFT images are hosted on different external URLs other than your NextJS server.
Congratulations, your Ethereum NFT Explorer is fully functional. This is what it displays when you search using this address: 0x7928dc4ed0bf505274f62f65fa4776fff2c2207e.
That marks the end of our journey of building an Ethereum NFT Explorer using NextJS. You can find the complete source code here.
Beyond This
To recap, you have learned how to:
- Connect to the Ethereum blockchain
- Fetch NFT data by wallet or collection address
- Display NFTs and their metadata in a basic UI using NextJS
As a challenge, I would encourage you to build an NFT Explorer:
- On multiple supported chains
- Using toggle bars to switch between chains.
Ciao