Nft Portfolio
Dashboard to view NFT holdings using a connected wallet or given address
NftsAnalyticsData
Loading
Technologies
Installation
Install Web3 dependencies
npm install @reown/appkit @reown/appkit-adapter-wagmi wagmi viem @tanstack/react-query alchemy-sdk
This component uses the Reown Appkit for web3 wallet connectivity which requires additional configurations to your project. To ensure it works properly follow the Install Reown Appkit setup guide.
Add Shadcn components
npx shadcn@latest add card input skeleton button
This component uses the Shadcn component library which requires additional configurations to your project. To ensure it works properly follow the Install Shadcn UI setup guide.
Add utils file
Create a file named
lib/utils.ts
and add the following code:import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Add custom hook
Create a file named
hooks/useFetchUserNfts.ts
and add the following code:import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
Alchemy,
Network,
OwnedNft,
GetNftsForOwnerOptions,
} from "alchemy-sdk";
import { isAddress, getAddress } from "viem";
const settings = {
apiKey: process.env.ALCHEMY_API_KEY, // Remember to setup your Alchemy API Key.
network: Network.ETH_MAINNET, // Replace with your network.
};
const alchemy = new Alchemy(settings);
interface FetchNftsArgs {
ownerAddress: string;
pageKey?: string | null;
}
interface NftsResponse {
ownedNfts: OwnedNft[];
pageKey?: string;
totalCount?: number;
}
const fetchNfts = async ({
ownerAddress,
pageKey,
}: FetchNftsArgs): Promise<NftsResponse> => {
if (!ownerAddress) {
throw new Error("Owner address is required");
}
if (!isAddress(ownerAddress)) {
throw new Error("Invalid Ethereum address");
}
const checksummedAddress = getAddress(ownerAddress);
const nfts = await alchemy.nft.getNftsForOwner(checksummedAddress, {
pageSize: 12,
pageKey,
} as GetNftsForOwnerOptions);
return {
ownedNfts: nfts.ownedNfts,
pageKey: nfts.pageKey,
totalCount: nfts.totalCount,
};
};
export const useFetchUserNftsQuery = (
ownerAddress: string,
pageKey?: string | null
): UseQueryResult<NftsResponse, Error> => {
return useQuery<NftsResponse, Error>({
queryKey: ["userNfts", pageKey],
queryFn: () => fetchNfts({ ownerAddress, pageKey }),
enabled: !!ownerAddress,
});
};
This component uses the Alchemy SDK. In order for this to work properly you must first setup a
.env
file in the root of your directory and create a variable named ALCHEMY_API_KEY
where you will pass in your MAINNET api key. You can create a new mainnet api key for free by visiting the Alchemy user dashboard.Copy and paste source code
"use client";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import Image from "next/image";
import { useFetchUserNftsQuery } from "@/hooks/useFetchUserNfts";
import { Skeleton } from "@/components/ui/skeleton";
import { useAccount } from "wagmi";
import { isAddress, getAddress } from "viem";
import { cn } from "@/lib/utils";
import { SearchXIcon, CircleXIcon } from "lucide-react";
const fallbackImage = "https://opensea.io/static/images/placeholder.png";
const NftPortfolioDashboard = () => {
const [mounted, setMounted] = useState<boolean>(false);
const [pageKeys, setPageKeys] = useState<string[]>([""]);
const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
const [activePageKey, setActivePageKey] = useState<string>("");
const [totalNfts, setTotalNfts] = useState<number | null>(null);
const [queryAddress, setQueryAddress] = useState<string>("");
const [queryAddressInvalid, setQueryAddressInvalid] =
useState<boolean>(false);
const { isConnected, isReconnecting, address } = useAccount();
const { data, isLoading, isSuccess, isError } = useFetchUserNftsQuery(
isConnected && address ? address : queryAddress,
activePageKey
);
useEffect(() => {
if (isSuccess && data.pageKey) {
setPageKeys((prevKeys) => {
if (!prevKeys.includes(data.pageKey as string)) {
return [...prevKeys, data.pageKey as string];
}
return prevKeys;
});
}
if (isSuccess && data.totalCount && totalNfts === null) {
setTotalNfts(data.totalCount);
}
}, [isSuccess, data, totalNfts]);
const handleNextClick = () => {
if (currentPageIndex < pageKeys.length - 1) {
setCurrentPageIndex((prevIndex) => prevIndex + 1);
setActivePageKey(pageKeys[currentPageIndex + 1]);
}
};
const handlePrevClick = () => {
if (currentPageIndex > 0) {
setCurrentPageIndex((prevIndex) => prevIndex - 1);
setActivePageKey(pageKeys[currentPageIndex - 1]);
} else {
setActivePageKey("");
}
};
const handleInputChange = (e: string) => {
const newVal = e;
if (newVal === "") {
setQueryAddressInvalid(false);
setQueryAddress("");
return;
}
const isValidAddress = isAddress(newVal);
if (!isValidAddress) {
setQueryAddressInvalid(true);
setQueryAddress(newVal);
} else {
setQueryAddressInvalid(false);
setQueryAddress(getAddress(newVal));
}
};
const shortenAddress = (address: `0x${string}` | undefined) => {
if (!address) return "";
return `${address.slice(0, 4)}...${address.slice(-4)}`;
};
useEffect(() => {
setMounted(true);
}, []);
if (!mounted)
return (
<div className="w-full h-56 flex items-center">
<div className="flex justify-center items-center gap-2 w-full h-44 text-[#A1A1AA]">
<div className="w-3.5 h-3.5 border-2 border-gray-400 border-b-transparent rounded-full inline-block box-border animate-spin" />
Loading
</div>
</div>
);
return (
<div
className={cn(
"w-full min-h-screen bg-[#0a0a0a] p-10 text-black border-[0.5px] border-white/10",
// *INFO - This is only used to make the component look better in preview mode
"rounded-2xl"
)}
>
{(isConnected && address && !isReconnecting) ||
(queryAddress.length > 0 && !queryAddressInvalid) ? (
<div className=" max-w-[76rem] mx-auto flex flex-col gap-10 text-white">
<div className="flex justify-between items-start font-semibold border-b border-white/10 pb-4">
<div>
<h2 className="">Hello ser</h2>
<h1 className=" text-4xl ">
{shortenAddress(
queryAddress.length > 0 && !queryAddressInvalid
? (queryAddress as `0x${string}`)
: address
)}
</h1>
</div>
<div className="items-center hidden md:flex">
<span className="text-3xl">{totalNfts}</span>
<Image
src="https://cdn3d.iconscout.com/3d/premium/thumb/nft-3d-illustration-download-in-png-blend-fbx-gltf-file-formats--nonfungible-token-tokens-crypto-pack-cryptocurrency-illustrations-3061789.png?f=webp"
alt="Profile Picture"
width={70}
height={70}
className="rounded-full"
/>
</div>
</div>
<div className="flex flex-wrap gap-10">
{isLoading ? (
Array.from({ length: 12 }).map((_, index) => (
<NftSkeletonCard key={index} />
))
) : isSuccess && data.ownedNfts.length > 0 ? (
data.ownedNfts.map((nft) => (
<NftAssetCard
key={nft.tokenId}
imageUrl={nft.image.pngUrl ? nft.image.pngUrl : fallbackImage}
collectionName={nft.collection?.name}
nftName={nft.name}
tokenId={nft.tokenId}
/>
))
) : isSuccess && data.ownedNfts.length === 0 ? (
<div className="flex items-center gap-2">
<span>No NFTs found for this wallet address</span>
<SearchXIcon />
</div>
) : isError ? (
<div className="flex items-center gap-2">
<span>
An error has occured fetching the NFTs for this wallet address
</span>
<CircleXIcon />
</div>
) : (
<></>
)}
</div>
<div>
<div className="h-[0.5px] bg-white/15 w-full" />
<div className="flex justify-between items-center mt-4">
<div className="hidden sm:block ">
<p className="text-sm text-gray-200">
Showing{" "}
<span className="font-medium">
{currentPageIndex * 12 + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min((currentPageIndex + 1) * 12, totalNfts || 0)}
</span>{" "}
of <span className="font-medium">{totalNfts}</span> results
</p>
</div>
{totalNfts !== null && totalNfts > 12 && (
<div className="flex gap-4">
<Button
onClick={handlePrevClick}
disabled={currentPageIndex === 0}
className=" text-white border-[0.5px] border-white/10 bg-zinc-900 hover:bg-zinc-900/80 rounded-[4px]"
>
Prev
</Button>
<Button
onClick={handleNextClick}
disabled={(currentPageIndex + 1) * 12 >= totalNfts!}
className=" text-white border-[0.5px] border-white/10 bg-zinc-900 hover:bg-zinc-900/80 rounded-[4px]"
>
Next
</Button>
</div>
)}
</div>
</div>
</div>
) : (
<div className="flex flex-col md:flex-row justify-center items-center h-screen gap-4">
<w3m-button />
<span className="text-white">Or</span>
<div className="flex flex-col gap-1 relative">
<Input
value={queryAddress}
onChange={(e) => handleInputChange(e.target.value)}
placeholder="Enter an address to view NFTs"
className={cn(
"w-[260px] h-[40.8px] rounded-full focus-visible:ring-0 text-white",
queryAddressInvalid ? "border-red-500" : "border-gray-600"
)}
/>
{queryAddressInvalid && (
<p className="text-red-500 text-xs pl-3 absolute -bottom-5">
Error invalid ethereum address
</p>
)}
</div>
</div>
)}
</div>
);
};
export default NftPortfolioDashboard;
interface Props {
imageUrl: string;
collectionName: string | undefined;
nftName: string | undefined;
tokenId: string;
}
const NftAssetCard = ({
imageUrl,
collectionName,
nftName,
tokenId,
}: Props) => {
const [imageError, setImageError] = useState<boolean>(false);
return (
<Card className="w-[270px] border-[0.5px] border-white/10 bg-zinc-900">
<CardHeader className="p-0">
<Image
src={imageError ? fallbackImage : imageUrl}
onError={() => setImageError(true)}
alt={`${collectionName} NFT`}
width={270}
height={270}
className="w-[270px] h-[270px] object-cover rounded-t-[10px]"
/>
</CardHeader>
<CardContent className="p-0 px-3 pt-3 pb-1 text-xs flex justify-between font-bold">
<h4 className="truncate">{nftName}</h4>
<p className="truncate">#{tokenId}</p>
</CardContent>
<CardFooter className="flex justify-between px-3 w-[200px] ">
<p className="truncate">
{collectionName != undefined ? collectionName : ""}
</p>
</CardFooter>
</Card>
);
};
const NftSkeletonCard = () => {
return (
<Card className="w-[270px] bg-white rounded-xl ">
<CardHeader className="p-3">
<Skeleton className="w-full h-[244px] rounded-t-[10px] bg-slate-900/20" />
</CardHeader>
<CardContent className="p-3 text-sm flex justify-between">
<Skeleton className=" h-4 w-32 rounded-xl bg-slate-900/20" />
<Skeleton className=" h-4 w-10 rounded-xl bg-slate-900/20" />
</CardContent>
<CardFooter className="px-3">
<Skeleton className=" h-4 w-full rounded-xl bg-slate-900/20" />
</CardFooter>
</Card>
);
};
"One of the only full-stack Web3 component libraries i've seen in the space so far. Ten out of ten recommended. Saved me a ton of time. Can't wait to see what templates they release next."
Samy
Side projects builder