Bitcoin

Create Your Own Ethereum NFT Explorer Using NextJS

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.

  1. On your dashboard, click on the “Apps” and “create new app” button:

  1. 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”

  1. Select the Ethereum Chain. 
  • Zoom out in case you don’t see the “next” button, and click on “Next”

  1. 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(

  fn: () => Promise,

  retries: number = 3,

  delay: number = 1000

): Promise {

  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 (

   

     

       

         

       

       

         

            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"

          />

         

     

   

  );

}

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 (

   

      {imageUrl ? (

       

          src={imageUrl}

          alt={data.name || "NFT Image"}

          width={500}

          height={500}

          unoptimized

        />

      ) : (

       

          Loading...

       

      )}

     

{data.name || No name provided}

     

        className="mt-2 cursor-pointer hover:underline relative"

        title={data.contract?.address}

        onClick={handleCopy}

      >

        {copied ? "Copied!" : shortAddress || No contract address}

     

     

        Token ID: {shortTokenId}

     

   

  );

}

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(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 (

       

            className="fixed inset-0 z-50 overflow-y-auto"

            aria-labelledby="modal-title"

            role="dialog"

            aria-modal="true"

        >

            {/* Backdrop */}

           

                className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"

                onClick={onClose}

            >

            {/* Modal */}

           

               

                    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`}

                >

                   

                       

                            {icon}

                       

                       

                           

                                className={`text-lg font-medium leading-6 ${titleColor}`}

                                id="modal-title"

                            >

                                {title}

                           

                           

                                {children}

                           

                       

                   

                   

                       

               

           

       

    );

}

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("");

  const [data, setData] = useState([]);

  const [loading, setLoading] = useState(false);

  const [hasSearched, setHasSearched] = useState(false);

  const [modal, setModal] = useState({

    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 => {

    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

  ): void => {

    setAddress(e.target.value);

  };

  const handleKeyPress = (e: React.KeyboardEvent): void => {

    if (e.key === "Enter") {

      getNfts();

    }

  };

  const EmptyState = () => (

   

     

🖼️

     

        No NFTs Found

     

     

        We couldn't find any NFTs for this wallet address. This could mean:

     

     

       

  • • The wallet doesn't own any NFTs
  •        

  • • The address might be incorrect
  •        

  • • The NFTs might not be indexed yet
  •      

         

      );

      const LoadingState = () => (

       

         

         

    Loading NFTs...

       

      );

    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.

        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"}

    Lastly, update our content area, by adding the NFTCard and Modal components:

    {/* Content Area */}

            {loading ? (

             

            ) : hasSearched && data.length === 0 ? (

             

            ) : data.length > 0 ? (

              <>

               

                  Found {data.length} NFT{data.length !== 1 ? 's' : ''}

               

               

                  {data.map((nft: NFTData) => (

                   

                      key={`${nft.contract.address}-${nft.tokenId}`}

                      data={nft}

                    />

                  ))}

               

              >

            ) : (

             

                Enter a wallet address above to explore NFTs

             

            )}

         

    Related Articles

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Back to top button