Skip to main content

How to build a portfolio viewer using Moralis API

When building a web3 app, it is essentially for the app to show some information about the connected wallet like ERC20 token balances, ERC721 token balances, transaction history etc...

Moralis has a wide range of out-of-the-box APIs for the most popular features like wallet native balance, fetch all NFTs and collections, wallet age and activity, and many more...

In this guide, you will learn how to build a simple ERC20 token balances view for the connected wallet using Moralis API and NextJS.

note

If you wish to try the portfolio viewer before building it, you can do so here

portfolio view

Requirements

Before you begin, you'll need:

  1. Moralis API Key: Get it from Moralis Dashboard
  2. Reown Project ID: Get it from Reown Cloud

Step 1: Initializing the project

Create Nextjs app

npx create-next-app@latest portfolio-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

Install ShadCN components

npx shadcn@latest add button card tabs

Install Tanstack React Query (for better async state management)

npm install @tanstack/react-query

Step 2: Environment Setup

Add your Moralis API key and Reown Project ID to .env.local:

MORALIS_API_KEY=
NEXT_PUBLIC_PROJECT_ID=

Step 3: Connect Wallet functionality

Create the ContextProvider component

Create a folder named context in the src directory, inside the folder create a file named index.ts

index.tssrc > context
"use client";
import { wagmiAdapter, projectId } from "@/config";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createAppKit } from "@reown/appkit/react";
import { monad } from "@reown/appkit/networks";
import React, { type ReactNode } from "react";
import { WagmiProvider, type Config } from "wagmi";
// Set up queryClient
const queryClient = new QueryClient();
if (!projectId) {
throw new Error("NEXT_PUBLIC_PROJECT_ID is not set");
}
// Set up metadata
const metadata = {
name: "Portfolio Viewer",
description: "View your crypto portfolio on Monad blockchain",
url: "your-app-url",
icons: ["your-app-icon"],
};
// Create the modal
const modal = createAppKit({
adapters: [wagmiAdapter],
projectId,
networks: [monad],
defaultNetwork: monad,
metadata: metadata,
features: {
analytics: true,
},
});
export function ContextProvider({ children }: { children: ReactNode }) {
return (
<WagmiProvider config={wagmiAdapter.wagmiConfig as Config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}

Wrap the app with ContextProvider component in layout.tsx

layout.tsxsrc > app
// imports
export const metadata: Metadata = {
title: "Monad Portfolio App",
description: "View your crypto portfolio on Monad blockchain",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={GeistSans.variable} style={{
// @ts-ignore
'--font-geist-sans': GeistSans.style.fontFamily,
'--font-geist-mono': GeistMono.style.fontFamily,
}}>
<body className="font-sans min-h-screen flex flex-col bg-dot-pattern">
<ContextProvider>
<main className="flex-1 pb-6">{children}</main>
</ContextProvider>
</body>
</html>
);
}

Create a custom ConnectButton

Create a file in components/wallet folder named ConnectButton.tsx

ConnectButton.tsxsrc > components > wallet
"use client";
import { useAppKit, useAppKitAccount, useDisconnect } from "@reown/appkit/react";
import { Button } from "@/components/ui/Button";
import { ConnectIcon, type ConnectIconHandle } from "@/components/ui/ConnectIcon";
export function ConnectButton() {
const { open } = useAppKit();
const { address, isConnected } = useAppKitAccount();
const { disconnect } = useDisconnect();
if (isConnected && address) {
return (
<div className="inline-flex rounded-md shadow-sm">
<a
href={`https://monadvision.com/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-8 h-10 bg-primary text-primary-foreground rounded-l-md font-semibold text-sm hover:bg-primary/90 transition-colors"
>
{formatAddress(address)}
</a>
<Button
onClick={() => disconnect()}
size="icon"
className="h-10 w-10 rounded-l-none border-l border-primary-foreground/20"
>
<ConnectIcon size={16} />
</Button>
</div>
);
}
return (
<Button
onClick={() => open()}
size="lg"
className="font-semibold shadow-sm gap-2"
>
<ConnectIcon ref={connectIconRef} size={20} />
Connect Wallet
</Button>
);
}

We are using the useAppKit and useAppKitAccount hook for creating a custom connect button instead of the standard one provided by Reown AppKit.

Create a Header component with custom ConnectButton

header

Create a file named Header.tsx in src/components/layout folder and import the newly created ConnectButton component into it.

Header.tsxsrc > components > layout
"use client";
import { ConnectButton } from "../wallet/ConnectButton";
import { ExternalLink } from "lucide-react";
export function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-foreground">
Portfolio App
</h1>
</div>
<div className="flex items-center gap-6">
<ConnectButton />
</div>
</div>
</header>
);
}

Import the Header component in layout.tsx

layout.tsxsrc > app
import { Header } from "@/components/layout/Header";
// rest of the code
<ContextProvider>
<Header />
<main className="flex-1 pb-6">{children}</main>
</ContextProvider>
// rest of the code

You should now be able to see a custom Connect Wallet button!

Step 4: Fetching connected wallet token balances

Creating a /api/wallet/balances route

Create a file named route.ts in the folder src/app/api/wallet/balances/

We are using the "Get Native & ERC20 Token Balances by Wallet" Moralis API endpoint to get the token balances. You can find the endpoint docs here.

It takes in an address and a chain id and returns a list of ERC20 tokens (including Logo, Name, Symbol) and native balance of the address.

route.tssrc > app > api > wallet > balances
import { NextRequest, NextResponse } from "next/server";
const MORALIS_API_BASE = "https://deep-index.moralis.io/api/v2.2";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const address = searchParams.get("address");
const chain = "0x8f"; // 0x8f = 143 (Monad chain ID in hex)
if (!address) {
return NextResponse.json(
{ error: "Address is required" },
{ status: 400 }
);
}
// Fetch ERC20 token balances with price data from Moralis
const data = await moralisRequest({
endpoint: `/wallets/${address}/tokens`,
params: {
chain,
},
});
return NextResponse.json(data);
} catch (error) {
console.error("Error fetching token balances:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to fetch token balances";
return NextResponse.json(
{
error: errorMessage,
},
{ status: 500 }
);
}
}
async function moralisRequest({
endpoint,
params,
}) {
const apiKey = process.env.MORALIS_API_KEY;
if (!apiKey) {
throw new Error("MORALIS_API_KEY is not set");
}
const url = new URL(`${MORALIS_API_BASE}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"X-API-Key": apiKey,
"Content-Type": "application/json",
},
next: {
revalidate: 30, // Cache for 30 seconds
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.message || `Moralis API error: ${response.status}`;
console.error("Moralis API Error:", {
status: response.status,
url: url.toString(),
error: errorData,
});
throw new Error(errorMessage);
}
return response.json();
}

Create a react hook called useTokenBalances

This react hook calls the /api/wallet/balances/ endpoint that you created and gets the token balances data, formats it into a proper list and makes it available to consume in the frontend.

useTokenBalances.tssrc > hooks
"use client";
import { useQuery } from "@tanstack/react-query";
async function fetchTokenBalances(
address: string,
) {
const response = await fetch(
`/api/wallet/balances?address=${address}`
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || "Failed to fetch token balances");
}
const data = await response.json();
// Check if data exists
if (!data) {
return [];
}
// Handle both response formats: direct array or object with result property
const tokens = Array.isArray(data) ? data : data.result;
if (!tokens || !Array.isArray(tokens)) {
return [];
}
// Transform Moralis data to our Token type
return tokens.map((token) => {
const balanceFormatted = parseFloat(
formatTokenBalance(token.balance, token.decimals)
);
// Calculate USD value if we have price but not value
const usdValue = token.usd_value !== undefined
? token.usd_value
: token.usd_price !== undefined
? token.usd_price * balanceFormatted
: undefined;
return {
address: token.token_address,
name: token.name,
symbol: token.symbol,
logo: token.logo || token.thumbnail,
decimals: token.decimals,
balance: token.balance,
balanceFormatted,
usdPrice: token.usd_price,
usdValue,
isNative: token.native_token || false,
isSpam: token.possible_spam,
verified: token.verified_contract,
};
});
}
export function useTokenBalances(address?: string, enabled: boolean = true) {
return useQuery({
queryKey: ["tokenBalances", address],
queryFn: () => fetchTokenBalances(address!),
enabled: enabled && !!address,
staleTime: 30000, // 30 seconds
});
}
function formatTokenBalance(
balance: string | number,
decimals: number = 18
): string {
const balanceNum = typeof balance === "string" ? parseFloat(balance) : balance;
const divisor = Math.pow(10, decimals);
const formattedBalance = balanceNum / divisor;
// Show more decimals for small amounts
if (formattedBalance < 0.01) {
return formattedBalance.toFixed(6);
} else if (formattedBalance < 1) {
return formattedBalance.toFixed(4);
} else {
return formattedBalance.toFixed(2);
}
}

We will later use this hook in the UI components

Step 5: Creating the UI

Create a TokenRow component

token-row

TokenRow component will display token details like name, symbol, logo, usd price, amount of tokens in connected wallet and the usd value.

Create a file named TokenRow.tsx in the folder src/components/portfolio/tokens/

TokenRow.tsxsrc > components > portfolio > tokens
import Image from "next/image";
export function TokenRow({ token }) {
return (
<div className="grid grid-cols-12 gap-4 px-6 py-4 hover:bg-muted/30 transition-colors">
{/* Token Info */}
<div className="col-span-4 flex items-center gap-3">
{token.logo ? (
<Image
src={token.logo}
alt={token.name}
width={40}
height={40}
className="rounded-full"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold border border-primary/20">
{token.symbol.charAt(0)}
</div>
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold truncate">{token.symbol}</p>
</div>
<p className="text-sm text-muted-foreground truncate">
{token.name}
</p>
</div>
</div>
{/* Price */}
<div className="col-span-2 flex items-center justify-end">
{token.usdPrice !== undefined && token.usdPrice > 0 ? (
<p className="text-sm font-medium">
${formatCurrency(token.usdPrice, 4)}
</p>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</div>
{/* Amount */}
<div className="col-span-3 flex items-center justify-end">
<p className="font-medium">{formatCurrency(token.balanceFormatted, 2)}</p>
</div>
{/* USD Value */}
<div className="col-span-3 flex items-center justify-end">
{token.usdValue !== undefined && token.usdValue > 0 ? (
<p className="font-semibold text-primary">
{formatUSD(token.usdValue)}
</p>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</div>
</div>
);
}
// Helper function to format values in the UI
function formatUSD(value: number): string {
if (value === 0) return "$0.00";
if (value < 0.01) return "< $0.01";
if (value < 1) return `$${value.toFixed(4)}`;
if (value < 1000) return `$${value.toFixed(2)}`;
if (value < 1000000) return `$${(value / 1000).toFixed(2)}K`;
return `$${(value / 1000000).toFixed(2)}M`;
}
function formatCurrency(value: number, decimals: number = 2): string {
return value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}

Create a TokenList component

token-list

Create a TokenList.tsx file in src/components/portfolio folder and import the newly created TokenRow.tsx component in it.

TokenList.tsxsrc > components > portfolio
import { TokenRow } from "./tokens/TokenRow";
export function TokenList({
tokens,
isLoading,
error,
showLowValueTokens = false,
}) {
if (isLoading) {
return (
// Loading state
);
}
if (error) {
return (
// Error state
);
}
if (tokens.length === 0) {
return (
// Empty state
);
}
return (
<div className="divide-y divide-border">
{tokens.map((token) => (
<TokenRow key={token.address} token={token} />
))}
</div>
);
}

Create a PortfolioDashboard component

dashboard

The PortfolioDashboard component will display the TokenList and can be further modified to have more components presenting information that you would like your user to see.

PortfolioDashboard.tsxsrc > components > portfolio
"use client";
import { useState, useEffect } from "react";
import { useAccount } from "wagmi";
import { useTokenBalances } from "@/hooks/useTokenBalances";
import { TokenList } from "./TokenList";
export function PortfolioDashboard() {
const { address, isConnected } = useAccount();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const {
data: tokens = [],
isLoading,
error,
refetch,
isFetching,
} = useTokenBalances(address, isConnected);
// Prevent hydration mismatch - don't render until mounted
if (!mounted) {
return null;
}
// Not connected state
if (!isConnected) {
return (
// UI for wallet not connected state
);
}
return (
<div className="space-y-8">
<TokenList
tokens={tokens}
isLoading={isLoading}
error={error?.message}
showLowValueTokens={showLowValueTokens}
/>
</div>
);
}
tip

Check out the other Moralis API endpoints and create visual components for the same!

Conclusion

In this guide you learned how to use Moralis API to get wallet token balances and presenting them in a UI.

It is highly recommended you check out the other API endpoints and add more features to this project you created!

You can find a complete portfolio dashboard project here for inspiration.