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: NextJSTailwind CSSAlchemy API NextJS Tailwind CSS Alchemy API It is also worth noting that for this tutorial, you will need to have: A basic understanding of React/NextJSA basic knowledge of Tailwind CSSVS Code 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. npx 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 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. default Next, navigate into the project folder with the cd command: cd nft-explorer cd nft-explorer Finally, open your project in a new VS Code window using the command: code . 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: 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”. 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” Zoom out in case you don’t see the “next” button and click on “Next” Zoom out in case you don’t see the “next” button and click on “Next” Select the Ethereum Chain. 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 “next” button, and click on “Next” 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. 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 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 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/ src/ ├─ app/ ├─ app/ │ ├─ api/ │ ├─ api/ │ │ ├─ getnfts/ │ │ ├─ getnfts/ │ │ │ ├─ route.ts │ │ │ ├─ 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 { NextRequest, NextResponse } from "next/server"; import { Alchemy, Network } from "alchemy-sdk"; import { Alchemy, Network } from "alchemy-sdk"; const config = { const config = { apiKey: process.env.ALCHEMY_API_KEY, apiKey: process.env.ALCHEMY_API_KEY, network: Network.ETH_MAINNET, network: Network.ETH_MAINNET, maxRetries: 3, maxRetries: 3, requestTimeout: 30000, // 30 seconds requestTimeout: 30000, // 30 seconds }; }; const alchemy = new Alchemy(config); const alchemy = new Alchemy(config); // Helper function to validate wallet address // Helper function to validate wallet address function isValidWalletAddress(address: string): boolean { function isValidWalletAddress(address: string): boolean { return /^0x[a-fA-F0-9]{40}$/.test(address); return /^0x[a-fA-F0-9]{40}$/.test(address); } } // Helper function for retry logic // Helper function for retry logic async function retryWithDelay<T>( async function retryWithDelay<T>( fn: () => Promise<T>, fn: () => Promise<T>, retries: number = 3, retries: number = 3, delay: number = 1000 delay: number = 1000 ): Promise<T> { ): Promise<T> { try { try { return await fn(); return await fn(); } catch (error) { } catch (error) { if (retries <= 0) throw error; if (retries <= 0) throw error; console.log(`Retrying in ${delay}ms... (${retries} retries left)`); console.log(`Retrying in ${delay}ms... (${retries} retries left)`); await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay)); return retryWithDelay(fn, retries - 1, delay * 2); return retryWithDelay(fn, retries - 1, delay * 2); } } } } export async function GET(req: NextRequest) { export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url); const wallet = searchParams.get("wallet"); const wallet = searchParams.get("wallet"); if (!wallet) { if (!wallet) { return NextResponse.json( return NextResponse.json( { error: "Wallet address is required" }, { error: "Wallet address is required" }, { status: 400 } { status: 400 } ); ); } } if (!isValidWalletAddress(wallet)) { if (!isValidWalletAddress(wallet)) { return NextResponse.json( return NextResponse.json( { error: "Invalid wallet address format" }, { error: "Invalid wallet address format" }, { status: 400 } { status: 400 } ); ); } } if (!process.env.ALCHEMY_API_KEY) { if (!process.env.ALCHEMY_API_KEY) { console.error("ALCHEMY_API_KEY is not configured"); console.error("ALCHEMY_API_KEY is not configured"); return NextResponse.json( return NextResponse.json( { error: "API configuration error" }, { error: "API configuration error" }, { status: 500 } { status: 500 } ); ); } } try { try { console.log(`Fetching NFTs for wallet: ${wallet}`); console.log(`Fetching NFTs for wallet: ${wallet}`); const results = await retryWithDelay( const results = await retryWithDelay( () => alchemy.nft.getNftsForOwner(wallet, { () => alchemy.nft.getNftsForOwner(wallet, { excludeFilters: [], // Optional: exclude spam/airdrops excludeFilters: [], // Optional: exclude spam/airdrops includeFilters: [], includeFilters: [], }), }), 3, 3, 1000 1000 ); ); console.log(`Successfully fetched ${results.ownedNfts.length} NFTs`); console.log(`Successfully fetched ${results.ownedNfts.length} NFTs`); return NextResponse.json({ return NextResponse.json({ message: "success", message: "success", data: results, data: results, count: results.ownedNfts.length count: results.ownedNfts.length }); }); } catch (error: any) { } catch (error: any) { console.error("Alchemy error:", error); console.error("Alchemy error:", error); if (error.message?.includes("401") || error.message?.includes("authenticated")) { if (error.message?.includes("401") || error.message?.includes("authenticated")) { return NextResponse.json( return NextResponse.json( { error: "API authentication failed. Please check your API key." }, { error: "API authentication failed. Please check your API key." }, { status: 401 } { status: 401 } ); ); } } if (error.code === 'ETIMEDOUT' || error.message?.includes("timeout")) { if (error.code === 'ETIMEDOUT' || error.message?.includes("timeout")) { return NextResponse.json( return NextResponse.json( { error: "Request timeout. The server took too long to respond." }, { error: "Request timeout. The server took too long to respond." }, { status: 408 } { status: 408 } ); ); } } if (error.message?.includes("rate limit")) { if (error.message?.includes("rate limit")) { return NextResponse.json( return NextResponse.json( { error: "Rate limit exceeded. Please try again later." }, { error: "Rate limit exceeded. Please try again later." }, { status: 429 } { status: 429 } ); ); } } return NextResponse.json( return NextResponse.json( { error: "Failed to fetch NFTs. Please try again later." }, { error: "Failed to fetch NFTs. Please try again later." }, { status: 500 } { 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. 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. exponential backoff 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 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"; "use client"; export default function Home() { export default function Home() { return ( return ( <div className="h-full mt-20 p-5"> <div className="h-full mt-20 p-5"> <div className="flex flex-col gap-10"> <div className="flex flex-col gap-10"> <div className="flex items-center justify-center"> <div className="flex items-center justify-center"> <h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1> <h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1> </div> </div> <div className="flex space-x-5 items-center justify-center"> <div className="flex space-x-5 items-center justify-center"> <input <input type="text" type="text" placeholder="Enter your wallet address" 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" 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"> <button className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer"> Get NFTs Get NFTs </button> </button> </div> </div> </div> </div> </div> </div> ); ); } } At this point, our main page should look like this: Building the NFT Cards Building the NFT Cards The NFT Cards will display the NFTs and other metadata received from our route.tsx file. metadata 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/ src/ └── app/ └── app/ ├── api/ ├── api/ │ └── getnfts/ │ └── getnfts/ │ └── route.ts │ └── route.ts ├── components/ ├── components/ │ └── NFTCard.tsx │ └── NFTCard.tsx ├── favicon.ico ├── favicon.ico ├── globals.css ├── globals.css ├── layout.tsx ├── layout.tsx └── page.tsx └── page.tsx Afterwards, copy and paste the code below: import { useEffect, useState } from "react"; import { useEffect, useState } from "react"; import Image from "next/image"; import Image from "next/image"; const IPFS_URL = "ipfs://"; const IPFS_URL = "ipfs://"; const IPFS_GATEWAY_URL = "https://ipfs.io/ipfs/"; const IPFS_GATEWAY_URL = " https://ipfs.io/ipfs/"; https://ipfs.io/ipfs/"; interface ImageData { interface ImageData { originalUrl?: string; originalUrl?: string; cachedUrl?: string; cachedUrl?: string; } } interface ContractData { interface ContractData { address?: string; address?: string; } } interface Metadata { interface Metadata { image?: string; image?: string; } } interface Data { interface Data { image?: ImageData; image?: ImageData; tokenUri?: string | { raw?: string }; tokenUri?: string | { raw?: string }; contract?: ContractData; contract?: ContractData; tokenId: string; tokenId: string; name?: string; name?: string; } } interface NFTCardProps { interface NFTCardProps { data: Data; data: Data; } } export default function NFTCard({ data }: NFTCardProps) { export default function NFTCard({ data }: NFTCardProps) { const [imageUrl, setImageUrl] = useState(null); const [imageUrl, setImageUrl] = useState(null); const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false); useEffect(() => { useEffect(() => { const resolveImageUrl = async () => { const resolveImageUrl = async () => { let rawUrl = data?.image?.originalUrl || data?.image?.cachedUrl; let rawUrl = data?.image?.originalUrl || data?.image?.cachedUrl; if (!rawUrl) { if (!rawUrl) { let tokenUri = let tokenUri = typeof data?.tokenUri === "string" typeof data?.tokenUri === "string" ? data.tokenUri ? data.tokenUri : data?.tokenUri?.raw; : data?.tokenUri?.raw; if (tokenUri?.startsWith(IPFS_URL)) { if (tokenUri?.startsWith(IPFS_URL)) { tokenUri = tokenUri.replace(IPFS_URL, IPFS_GATEWAY_URL); tokenUri = tokenUri.replace(IPFS_URL, IPFS_GATEWAY_URL); } } try { try { const res = await fetch(tokenUri); const res = await fetch(tokenUri); const metadata: Metadata = await res.json(); const metadata: Metadata = await res.json(); rawUrl = metadata?.image; rawUrl = metadata?.image; } catch (err) { } catch (err) { console.error("Failed to load metadata:", err); console.error("Failed to load metadata:", err); } } } } if (!rawUrl) return; if (!rawUrl) return; const finalUrl = rawUrl.startsWith(IPFS_URL) const finalUrl = rawUrl.startsWith(IPFS_URL) ? rawUrl.replace(IPFS_URL, IPFS_GATEWAY_URL) ? rawUrl.replace(IPFS_URL, IPFS_GATEWAY_URL) : rawUrl; : rawUrl; setImageUrl(finalUrl); setImageUrl(finalUrl); }; }; resolveImageUrl(); resolveImageUrl(); }, [data]); }, [data]); const handleCopy = async () => { const handleCopy = async () => { try { try { await navigator.clipboard.writeText(data.contract?.address || ""); await navigator.clipboard.writeText(data.contract?.address || ""); setCopied(true); setCopied(true); setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000); } catch (err) { } catch (err) { console.error("Failed to copy:", err); console.error("Failed to copy:", err); } } }; }; const shortAddress = data.contract?.address const shortAddress = data.contract?.address ? data.contract.address.slice(0, 20) + "..." ? data.contract.address.slice(0, 20) + "..." : null; : null; const shortTokenId = const shortTokenId = data.tokenId.length > 20 ? data.tokenId.slice(0, 20) + "..." : data.tokenId; data.tokenId.length > 20 ? data.tokenId.slice(0, 20) + "..." : data.tokenId; return ( return ( <div className="p-5 border rounded-lg flex flex-col"> <div className="p-5 border rounded-lg flex flex-col"> {imageUrl ? ( {imageUrl ? ( <Image <Image src={imageUrl} src={imageUrl} alt={data.name || "NFT Image"} alt={data.name || "NFT Image"} width={500} width={500} height={500} height={500} unoptimized unoptimized /> /> ) : ( ) : ( <div className="w-full h-full bg-gray-200 flex items-center justify-center text-gray-500"> <div className="w-full h-full bg-gray-200 flex items-center justify-center text-gray-500"> Loading... Loading... </div> </div> )} )} <div className="mt-2">{data.name || <i>No name provided</i>}</div> <div className="mt-2">{data.name || <i>No name provided</i>}</div> <div <div className="mt-2 cursor-pointer hover:underline relative" className="mt-2 cursor-pointer hover:underline relative" title={data.contract?.address} title={data.contract?.address} onClick={handleCopy} onClick={handleCopy} > > {copied ? "Copied!" : shortAddress || <i>No contract address</i>} {copied ? "Copied!" : shortAddress || <i>No contract address</i>} </div> </div> <div className="mt-2" title={data.tokenId}> <div className="mt-2" title={data.tokenId}> Token ID: {shortTokenId} Token ID: {shortTokenId} </div> </div> </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. https://ipfs.io/ipfs/ The handleCopy function copies the contract address to the user’s clipboard and sets copied to true. Building the Modal Component Building the Modal Component "use client"; "use client"; import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react"; interface ModalProps { interface ModalProps { interface ModalProps { interface ModalProps { isOpen: boolean; isOpen: boolean; onClose: () => void; onClose: () => void; title: string; title: string; children: React.ReactNode; children: React.ReactNode; type?: "error" | "success" | "warning" | "info"; type?: "error" | "success" | "warning" | "info"; } } export default function Modal({ export default function Modal({ isOpen, isOpen, onClose, onClose, title, title, children, children, type = "error" type = "error" }: ModalProps) { }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null); // Handle escape key // Handle escape key useEffect(() => { useEffect(() => { const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { if (e.key === "Escape") { onClose(); onClose(); } } }; }; if (isOpen) { if (isOpen) { document.addEventListener("keydown", handleEscape); document.addEventListener("keydown", handleEscape); // Prevent body scroll when modal is open // Prevent body scroll when modal is open document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden"; } } return () => { return () => { document.removeEventListener("keydown", handleEscape); document.removeEventListener("keydown", handleEscape); document.body.style.overflow = "unset"; document.body.style.overflow = "unset"; }; }; }, [isOpen, onClose]); }, [isOpen, onClose]); // Focus management // Focus management useEffect(() => { useEffect(() => { if (isOpen && modalRef.current) { if (isOpen && modalRef.current) { modalRef.current.focus(); modalRef.current.focus(); } } }, [isOpen]); }, [isOpen]); if (!isOpen) return null; if (!isOpen) return null; const getIconAndColors = () => { const getIconAndColors = () => { switch (type) { switch (type) { case "error": case "error": return { return { icon: "❌", icon: "❌", bgColor: "bg-red-50", bgColor: "bg-red-50", borderColor: "border-red-200", borderColor: "border-red-200", iconBg: "bg-red-100", iconBg: "bg-red-100", titleColor: "text-red-800", titleColor: "text-red-800", textColor: "text-red-700" textColor: "text-red-700" }; }; case "success": case "success": return { return { icon: "✅", icon: "✅", bgColor: "bg-green-50", bgColor: "bg-green-50", borderColor: "border-green-200", borderColor: "border-green-200", iconBg: "bg-green-100", iconBg: "bg-green-100", titleColor: "text-green-800", titleColor: "text-green-800", textColor: "text-green-700" textColor: "text-green-700" }; }; case "warning": case "warning": return { return { icon: "⚠️", icon: "⚠️", bgColor: "bg-yellow-50", bgColor: "bg-yellow-50", borderColor: "border-yellow-200", borderColor: "border-yellow-200", iconBg: "bg-yellow-100", iconBg: "bg-yellow-100", titleColor: "text-yellow-800", titleColor: "text-yellow-800", textColor: "text-yellow-700" textColor: "text-yellow-700" }; }; default: default: return { return { icon: "ℹ️", icon: "ℹ️", bgColor: "bg-blue-50", bgColor: "bg-blue-50", borderColor: "border-blue-200", borderColor: "border-blue-200", iconBg: "bg-blue-100", iconBg: "bg-blue-100", titleColor: "text-blue-800", titleColor: "text-blue-800", textColor: "text-blue-700" textColor: "text-blue-700" }; }; } } }; }; const { icon, bgColor, borderColor, iconBg, titleColor, textColor } = getIconAndColors(); const { icon, bgColor, borderColor, iconBg, titleColor, textColor } = getIconAndColors(); return ( return ( <div <div className="fixed inset-0 z-50 overflow-y-auto" className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" aria-labelledby="modal-title" role="dialog" role="dialog" aria-modal="true" aria-modal="true" > > {/* Backdrop */} {/* Backdrop */} <div <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} onClick={onClose} ></div> ></div> {/* Modal */} {/* Modal */} <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div <div ref={modalRef} ref={modalRef} tabIndex={-1} 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`} 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> <div className={`mx-auto flex h-12 w-12 items-center justify-center rounded-full ${iconBg}`}> <div className={`mx-auto flex h-12 w-12 items-center justify-center rounded-full ${iconBg}`}> <span className="text-2xl">{icon}</span> <span className="text-2xl">{icon}</span> </div> </div> <div className="mt-3 text-center sm:mt-5"> <div className="mt-3 text-center sm:mt-5"> <h3 <h3 className={`text-lg font-medium leading-6 ${titleColor}`} className={`text-lg font-medium leading-6 ${titleColor}`} id="modal-title" id="modal-title" > > {title} {title} </h3> </h3> <div className={`mt-2 ${textColor}`}> <div className={`mt-2 ${textColor}`}> {children} {children} </div> </div> </div> </div> </div> </div> <div className="mt-5 sm:mt-6"> <div className="mt-5 sm:mt-6"> <button <button type="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" 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} onClick={onClose} > > Close Close </button> </button> </div> </div> </div> </div> </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 { useState } from "react"; import NFTCard from "./components/NFTCard"; import NFTCard from "./components/NFTCard"; import Modal from "./components/Modal"; import Modal from "./components/Modal"; Just below the new components import, copy and paste the code below into your page.tsx file: interface ImageData { interface ImageData { originalUrl?: string; originalUrl?: string; cachedUrl?: string; cachedUrl?: string; } } interface ContractData { interface ContractData { address: string; address: string; } } interface NFTData { interface NFTData { image?: ImageData; image?: ImageData; tokenUri?: string | { raw?: string }; tokenUri?: string | { raw?: string }; contract: ContractData; contract: ContractData; tokenId: string; tokenId: string; name?: string; name?: string; } } interface ApiResponse { interface ApiResponse { data: { data: { ownedNfts: NFTData[]; ownedNfts: NFTData[]; }; }; } } interface ApiError { interface ApiError { error: string; error: string; } } interface ModalState { interface ModalState { isOpen: boolean; isOpen: boolean; title: string; title: string; message: string; message: string; type: "error" | "success" | "warning" | "info"; 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. 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 [address, setAddress] = useState<string>(""); const [data, setData] = useState<NFTData[]>([]); const [data, setData] = useState<NFTData[]>([]); const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false); const [hasSearched, setHasSearched] = useState<boolean>(false); const [hasSearched, setHasSearched] = useState<boolean>(false); const [modal, setModal] = useState<ModalState>({ const [modal, setModal] = useState<ModalState>({ isOpen: false, isOpen: false, title: "", title: "", message: "", message: "", type: "error", type: "error", }); }); const showModal = ( const showModal = ( title: string, title: string, message: string, message: string, type: ModalState["type"] = "error" type: ModalState["type"] = "error" ) => { ) => { setModal({ setModal({ isOpen: true, isOpen: true, title, title, message, message, type, type, }); }); }; }; const closeModal = () => { const closeModal = () => { setModal((prev) => ({ ...prev, isOpen: false })); setModal((prev) => ({ ...prev, isOpen: false })); }; }; const getNfts = async (): Promise<void> => { const getNfts = async (): Promise<void> => { if (!address.trim()) { if (!address.trim()) { showModal( showModal( "Invalid Input", "Invalid Input", "Please enter a wallet address before searching.", "Please enter a wallet address before searching.", "warning" "warning" ); ); return; return; } } setLoading(true); setLoading(true); setHasSearched(true); setHasSearched(true); try { try { const response = await fetch(`./api/getnfts?wallet=${address}`); const response = await fetch(`./api/getnfts?wallet=${address}`); if (!response.ok) { if (!response.ok) { try { try { const errorData: ApiError = await response.json(); const errorData: ApiError = await response.json(); const errorMessage = const errorMessage = errorData.error || `HTTP error! status: ${response.status}`; errorData.error || `HTTP error! status: ${response.status}`; switch (response.status) { switch (response.status) { case 400: case 400: showModal( showModal( "Invalid Request", "Invalid Request", errorMessage || errorMessage || "The wallet address format is invalid. Please check and try again." "The wallet address format is invalid. Please check and try again." ); ); break; break; case 401: case 401: showModal( showModal( "Authentication Error", "Authentication Error", errorMessage || errorMessage || "API authentication failed. Please contact support." "API authentication failed. Please contact support." ); ); break; break; case 408: case 408: showModal( showModal( "Request Timeout", "Request Timeout", errorMessage || errorMessage || "The request took too long to complete. Please try again." "The request took too long to complete. Please try again." ); ); break; break; case 429: case 429: showModal( showModal( "Rate Limit Exceeded", "Rate Limit Exceeded", errorMessage || errorMessage || "Too many requests. Please wait a moment and try again." "Too many requests. Please wait a moment and try again." ); ); break; break; case 500: case 500: showModal( showModal( "Server Error", "Server Error", errorMessage || "Internal server error. Please try again later." errorMessage || "Internal server error. Please try again later." ); ); break; break; default: default: showModal( showModal( "Request Failed", "Request Failed", errorMessage || errorMessage || `Unexpected error occurred (${response.status}). Please try again.` `Unexpected error occurred (${response.status}). Please try again.` ); ); } } } catch { } catch { showModal( showModal( "Network Error", "Network Error", `Failed to fetch NFTs. Server responded with status ${response.status}.` `Failed to fetch NFTs. Server responded with status ${response.status}.` ); ); } } setData([]); setData([]); return; return; } } const responseData: ApiResponse = await response.json(); const responseData: ApiResponse = await response.json(); console.log(responseData); console.log(responseData); setData(responseData.data.ownedNfts); setData(responseData.data.ownedNfts); } catch (error) { } catch (error) { console.error("Error fetching NFTs:", error); console.error("Error fetching NFTs:", error); if (error instanceof TypeError && error.message.includes("fetch")) { if (error instanceof TypeError && error.message.includes("fetch")) { showModal( showModal( "Connection Error", "Connection Error", "Unable to connect to the server. Please check your internet connection and try again." "Unable to connect to the server. Please check your internet connection and try again." ); ); } else { } else { showModal( showModal( "Unexpected Error", "Unexpected Error", "An unexpected error occurred while fetching NFTs. Please try again." "An unexpected error occurred while fetching NFTs. Please try again." ); ); } } setData([]); setData([]); } finally { } finally { setLoading(false); setLoading(false); } } }; }; const handleAddressChange = ( const handleAddressChange = ( e: React.ChangeEvent<HTMLInputElement> e: React.ChangeEvent<HTMLInputElement> ): void => { ): void => { setAddress(e.target.value); setAddress(e.target.value); }; }; const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => { const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => { if (e.key === "Enter") { if (e.key === "Enter") { getNfts(); getNfts(); } } }; }; const EmptyState = () => ( const EmptyState = () => ( <div className="flex flex-col items-center justify-center py-20 px-5"> <div className="flex flex-col items-center justify-center py-20 px-5"> <div className="text-6xl mb-6">🖼️</div> <div className="text-6xl mb-6">🖼️</div> <h2 className="text-2xl font-semibold text-gray-600 mb-3"> <h2 className="text-2xl font-semibold text-gray-600 mb-3"> No NFTs Found No NFTs Found </h2> </h2> <p className="text-gray-500 text-center max-w-md mb-6"> <p className="text-gray-500 text-center max-w-md mb-6"> We couldn&apos;t find any NFTs for this wallet address. This could mean: We couldn&apos;t find any NFTs for this wallet address. This could mean: </p> </p> <ul className="text-gray-500 text-sm space-y-2 mb-8"> <ul className="text-gray-500 text-sm space-y-2 mb-8"> <li>• The wallet doesn&apos;t own any NFTs</li> <li>• The wallet doesn&apos;t own any NFTs</li> <li>• The address might be incorrect</li> <li>• The address might be incorrect</li> <li>• The NFTs might not be indexed yet</li> <li>• The NFTs might not be indexed yet</li> </ul> </ul> <button <button onClick={() => { onClick={() => { setAddress(""); setAddress(""); setHasSearched(false); setHasSearched(false); setData([]); setData([]); }} }} className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all" className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all" > > Try Another Address Try Another Address </button> </button> </div> </div> ); ); const LoadingState = () => ( const LoadingState = () => ( <div className="flex flex-col items-center justify-center py-20"> <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> <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> <p className="text-gray-600">Loading NFTs...</p> </div> </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} value={address} onChange={handleAddressChange} onChange={handleAddressChange} onKeyDown={handleKeyPress} onKeyDown={handleKeyPress} disabled={loading} disabled={loading} Update the button tag: onClick={getNfts} onClick={getNfts} disabled={loading} 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 <button className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer" className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer" onClick={getNfts} onClick={getNfts} disabled={loading} disabled={loading} > > {loading ? "Loading..." : "Get NFTs"} {loading ? "Loading..." : "Get NFTs"} </button> </button> Lastly, update our content area, by adding the NFTCard and Modal components: {/* Content Area */} {/* Content Area */} {loading ? ( {loading ? ( <LoadingState /> <LoadingState /> ) : hasSearched && data.length === 0 ? ( ) : hasSearched && data.length === 0 ? ( <EmptyState /> <EmptyState /> ) : data.length > 0 ? ( ) : data.length > 0 ? ( <> <> <div className="text-center text-gray-600"> <div className="text-center text-gray-600"> Found {data.length} NFT{data.length !== 1 ? 's' : ''} Found {data.length} NFT{data.length !== 1 ? 's' : ''} </div> </div> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5"> {data.map((nft: NFTData) => ( {data.map((nft: NFTData) => ( <NFTCard <NFTCard key={`${nft.contract.address}-${nft.tokenId}`} key={`${nft.contract.address}-${nft.tokenId}`} data={nft} data={nft} /> /> ))} ))} </div> </div> </> </> ) : ( ) : ( <div className="text-center text-gray-500 py-20"> <div className="text-center text-gray-500 py-20"> Enter a wallet address above to explore NFTs Enter a wallet address above to explore NFTs </div> </div> )} )} </div> </div> {/* Modal */} {/* Modal */} <Modal <Modal isOpen={modal.isOpen} isOpen={modal.isOpen} onClose={closeModal} onClose={closeModal} title={modal.title} title={modal.title} type={modal.type} type={modal.type} > > <p className="text-sm">{modal.message}</p> <p className="text-sm">{modal.message}</p> </Modal> </Modal> </div> </div> Final Look This is what our page.tsx should look like: "use client"; "use client"; import { useState } from "react"; import { useState } from "react"; import NFTCard from "./components/NFTCard"; import NFTCard from "./components/NFTCard"; import NFTCard from "./components/NFTCard";import NFTCard from "./components/NFTCard"; import NFTCard from "./components/NFTCard";import NFTCard from "./components/NFTCard"; import Modal from "./components/Modal"; import Modal from "./components/Modal"; interface ImageData { interface ImageData { originalUrl?: string; originalUrl?: string; cachedUrl?: string; cachedUrl?: string; } } interface ContractData { interface ContractData { address: string; address: string; } } interface NFTData { interface NFTData { image?: ImageData; image?: ImageData; tokenUri?: string | { raw?: string }; tokenUri?: string | { raw?: string }; contract: ContractData; contract: ContractData; tokenId: string; tokenId: string; name?: string; name?: string; } } interface ApiResponse { interface ApiResponse { data: { data: { ownedNfts: NFTData[]; ownedNfts: NFTData[]; }; }; } } interface ApiError { interface ApiError { error: string; error: string; } } interface ModalState { interface ModalState { isOpen: boolean; isOpen: boolean; title: string; title: string; message: string; message: string; type: "error" | "success" | "warning" | "info"; type: "error" | "success" | "warning" | "info"; } } export default function Home() { export default function Home() { const [address, setAddress] = useState<string>(""); const [address, setAddress] = useState<string>(""); const [data, setData] = useState<NFTData[]>([]); const [data, setData] = useState<NFTData[]>([]); const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false); const [hasSearched, setHasSearched] = useState<boolean>(false); const [hasSearched, setHasSearched] = useState<boolean>(false); const [modal, setModal] = useState<ModalState>({ const [modal, setModal] = useState<ModalState>({ isOpen: false, isOpen: false, title: "", title: "", message: "", message: "", type: "error", type: "error", }); }); const showModal = ( const showModal = ( title: string, title: string, message: string, message: string, type: ModalState["type"] = "error" type: ModalState["type"] = "error" ) => { ) => { setModal({ setModal({ isOpen: true, isOpen: true, title, title, message, message, type, type, }); }); }; }; const closeModal = () => { const closeModal = () => { setModal((prev) => ({ ...prev, isOpen: false })); setModal((prev) => ({ ...prev, isOpen: false })); }; }; const getNfts = async (): Promise<void> => { const getNfts = async (): Promise<void> => { if (!address.trim()) { if (!address.trim()) { showModal( showModal( "Invalid Input", "Invalid Input", "Please enter a wallet address before searching.", "Please enter a wallet address before searching.", "warning" "warning" ); ); return; return; } } setLoading(true); setLoading(true); setHasSearched(true); setHasSearched(true); try { try { const response = await fetch(`./api/getnfts?wallet=${address}`); const response = await fetch(`./api/getnfts?wallet=${address}`); if (!response.ok) { if (!response.ok) { try { try { const errorData: ApiError = await response.json(); const errorData: ApiError = await response.json(); const errorMessage = const errorMessage = errorData.error || `HTTP error! status: ${response.status}`; errorData.error || `HTTP error! status: ${response.status}`; switch (response.status) { switch (response.status) { case 400: case 400: showModal( showModal( "Invalid Request", "Invalid Request", errorMessage || errorMessage || "The wallet address format is invalid. Please check and try again." "The wallet address format is invalid. Please check and try again." ); ); break; break; case 401: case 401: showModal( showModal( "Authentication Error", "Authentication Error", errorMessage || errorMessage || "API authentication failed. Please contact support." "API authentication failed. Please contact support." ); ); break; break; case 408: case 408: showModal( showModal( "Request Timeout", "Request Timeout", errorMessage || errorMessage || "The request took too long to complete. Please try again." "The request took too long to complete. Please try again." ); ); break; break; case 429: case 429: showModal( showModal( "Rate Limit Exceeded", "Rate Limit Exceeded", errorMessage || errorMessage || "Too many requests. Please wait a moment and try again." "Too many requests. Please wait a moment and try again." ); ); break; break; case 500: case 500: showModal( showModal( "Server Error", "Server Error", errorMessage || "Internal server error. Please try again later." errorMessage || "Internal server error. Please try again later." ); ); break; break; default: default: showModal( showModal( "Request Failed", "Request Failed", errorMessage || errorMessage || `Unexpected error occurred (${response.status}). Please try again.` `Unexpected error occurred (${response.status}). Please try again.` ); ); } } } catch { } catch { showModal( showModal( "Network Error", "Network Error", `Failed to fetch NFTs. Server responded with status ${response.status}.` `Failed to fetch NFTs. Server responded with status ${response.status}.` ); ); } } setData([]); setData([]); return; return; } } const responseData: ApiResponse = await response.json(); const responseData: ApiResponse = await response.json(); console.log(responseData); console.log(responseData); setData(responseData.data.ownedNfts); setData(responseData.data.ownedNfts); } catch (error) { } catch (error) { console.error("Error fetching NFTs:", error); console.error("Error fetching NFTs:", error); if (error instanceof TypeError && error.message.includes("fetch")) { if (error instanceof TypeError && error.message.includes("fetch")) { showModal( showModal( "Connection Error", "Connection Error", "Unable to connect to the server. Please check your internet connection and try again." "Unable to connect to the server. Please check your internet connection and try again." ); ); } else { } else { showModal( showModal( "Unexpected Error", "Unexpected Error", "An unexpected error occurred while fetching NFTs. Please try again." "An unexpected error occurred while fetching NFTs. Please try again." ); ); } } setData([]); setData([]); } finally { } finally { setLoading(false); setLoading(false); } } }; }; const handleAddressChange = ( const handleAddressChange = ( e: React.ChangeEvent<HTMLInputElement> e: React.ChangeEvent<HTMLInputElement> ): void => { ): void => { setAddress(e.target.value); setAddress(e.target.value); }; }; const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => { const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => { if (e.key === "Enter") { if (e.key === "Enter") { getNfts(); getNfts(); } } }; }; const EmptyState = () => ( const EmptyState = () => ( <div className="flex flex-col items-center justify-center py-20 px-5"> <div className="flex flex-col items-center justify-center py-20 px-5"> <div className="text-6xl mb-6">🖼️</div> <div className="text-6xl mb-6">🖼️</div> <h2 className="text-2xl font-semibold text-gray-600 mb-3"> <h2 className="text-2xl font-semibold text-gray-600 mb-3"> No NFTs Found No NFTs Found </h2> </h2> <p className="text-gray-500 text-center max-w-md mb-6"> <p className="text-gray-500 text-center max-w-md mb-6"> We couldn&apos;t find any NFTs for this wallet address. This could mean: We couldn&apos;t find any NFTs for this wallet address. This could mean: </p> </p> <ul className="text-gray-500 text-sm space-y-2 mb-8"> <ul className="text-gray-500 text-sm space-y-2 mb-8"> <li>• The wallet doesn&apos;t own any NFTs</li> <li>• The wallet doesn&apos;t own any NFTs</li> <li>• The address might be incorrect</li> <li>• The address might be incorrect</li> <li>• The NFTs might not be indexed yet</li> <li>• The NFTs might not be indexed yet</li> </ul> </ul> <button <button onClick={() => { onClick={() => { setAddress(""); setAddress(""); setHasSearched(false); setHasSearched(false); setData([]); setData([]); }} }} className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all" className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all" > > Try Another Address Try Another Address </button> </button> </div> </div> ); ); const LoadingState = () => ( const LoadingState = () => ( <div className="flex flex-col items-center justify-center py-20"> <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> <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> <p className="text-gray-600">Loading NFTs...</p> </div> </div> ); ); return ( return ( <div className="h-full mt-20 p-5"> <div className="h-full mt-20 p-5"> <div className="flex flex-col gap-10"> <div className="flex flex-col gap-10"> <div className="flex items-center justify-center"> <div className="flex items-center justify-center"> <h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1> <h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1> </div> </div> <div className="flex space-x-5 items-center justify-center"> <div className="flex space-x-5 items-center justify-center"> <input <input type="text" type="text" placeholder="Enter your wallet address" 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" className="px-5 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" value={address} value={address} onChange={handleAddressChange} onChange={handleAddressChange} onKeyDown={handleKeyPress} onKeyDown={handleKeyPress} disabled={loading} disabled={loading} /> /> <button <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" 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} onClick={getNfts} disabled={loading} disabled={loading} > > {loading ? "Loading..." : "Get NFTs"} {loading ? "Loading..." : "Get NFTs"} </button> </button> </div> </div> {/* Content Area */} {/* Content Area */} {loading ? ( {loading ? ( <LoadingState /> <LoadingState /> ) : hasSearched && data.length === 0 ? ( ) : hasSearched && data.length === 0 ? ( <EmptyState /> <EmptyState /> ) : data.length > 0 ? ( ) : data.length > 0 ? ( <> <> <div className="text-center text-gray-600"> <div className="text-center text-gray-600"> Found {data.length} NFT{data.length !== 1 ? "s" : ""} Found {data.length} NFT{data.length !== 1 ? "s" : ""} </div> </div> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5"> {data.map((nft: NFTData) => ( {data.map((nft: NFTData) => ( <NFTCard <NFTCard key={`${nft.contract.address}-${nft.tokenId}`} key={`${nft.contract.address}-${nft.tokenId}`} data={nft} data={nft} /> /> ))} ))} </div> </div> </> </> ) : ( ) : ( <div className="text-center text-gray-500 py-20"> <div className="text-center text-gray-500 py-20"> Enter a wallet address above to explore NFTs Enter a wallet address above to explore NFTs </div> </div> )} )} </div> </div> {/* Modal */} {/* Modal */} <Modal <Modal isOpen={modal.isOpen} isOpen={modal.isOpen} onClose={closeModal} onClose={closeModal} title={modal.title} title={modal.title} type={modal.type} type={modal.type} > > <p className="text-sm">{modal.message}</p> <p className="text-sm">{modal.message}</p> </Modal> </Modal> </div> </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} */ /** @type {import('next').NextConfig} */ const nextConfig = { const nextConfig = { images: { images: { remotePatterns: [ remotePatterns: [ { { protocol: "https", protocol: "https", hostname: "ipfs.io", hostname: "ipfs.io", }, }, { { protocol: "https", protocol: "https", hostname: "nft-cdn.alchemy.com", hostname: "nft-cdn.alchemy.com", }, }, { { protocol: "https", protocol: "https", hostname: "res.cloudinary.com", hostname: "res.cloudinary.com", }, }, { { protocol: "https", protocol: "https", hostname: "i.seadn.io", hostname: "i.seadn.io", }, }, { { protocol: "https", protocol: "https", hostname: "www.troublemaker.fun", hostname: "www.troublemaker.fun", }, }, { { protocol: "https", protocol: "https", hostname: "y.at", hostname: "y.at", }, }, { { protocol: "https", protocol: "https", hostname: "**", hostname: "**", }, }, ], ], }, }, }; }; export default nextConfig; 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. here Beyond This To recap, you have learned how to: Connect to the Ethereum blockchainFetch NFT data by wallet or collection addressDisplay NFTs and their metadata in a basic UI using NextJS 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 chainsUsing toggle bars to switch between chains. On multiple supported chains Using toggle bars to switch between chains. Ciao