In this guide, you'll fetch token balances for a wallet across multiple blockchain networks in a single API call and display them in a React component grouped by chain.
- An Alchemy API key
- A React app
Pick the chains you want to query using the Portfolio API network identifiers. For example:
eth-mainnet, polygon-mainnet, base-mainnet, arb-mainnet, opt-mainnet, sol-mainnet
Send a single POST request to the Token Balances by Wallet endpoint with the wallet address and your target networks.
curl -sX POST "https://api.g.alchemy.com/data/v1/<YOUR_API_KEY>/assets/tokens/balances/by-address" \
-H "content-type: application/json" \
-d '{
"addresses": [{
"address": "vitalik.eth",
"networks": ["eth-mainnet","polygon-mainnet","base-mainnet","arb-mainnet","opt-mainnet"]
}]
}'The response contains an array of fungible tokens (native + ERC-20/SPL) with balances for each wallet/network pair, plus a pageKey if there are more results to page through.
For human-readable amounts and token names or logos, look up each token's decimals and metadata via alchemy_getTokenMetadata.
If you want USD values and totals, join the results with Alchemy's Prices API.
Create a /components folder in the /app directory of your Next.js project and add a file called CrossChainBalances.tsx. The component below:
- Calls the Portfolio API once per network (with pagination support if
pageKeyis returned) - Groups balances by
network - Formats token balances using token decimals from
alchemy_getTokenMetadata
// /components/CrossChainBalances.tsx
"use client";
import React, { useEffect, useState } from "react";
/**
* Minimal types for the Portfolio API response shape we use here.
* (The API may include more fields; we only type what we read.)
*/
type NetworkId =
| "eth-mainnet"
| "polygon-mainnet"
| "base-mainnet"
| "arb-mainnet"
| "opt-mainnet";
type TokenRow = {
network: string; // e.g. "eth-mainnet"
tokenAddress?: string | null;
tokenBalance?: string | null; // raw string from API
symbol?: string;
name?: string;
decimals?: number;
};
type PortfolioResponse = {
data: {
tokens: TokenRow[];
pageKey?: string;
};
};
type Props = {
address: string; // ENS or 0x-address
networks?: NetworkId[]; // override if you want
apiKey?: string; // optionally pass explicitly; otherwise reads from env
onAddressChange?: (newAddress: string) => void; // callback for address changes
};
/**
* Super-simple, TS-safe component:
* - Calls the Portfolio API once (with pagination if pageKey is returned)
* - Groups tokens by network
* - Displays a small list per network (no native/decimals/price logic)
*/
export default function CrossChainBalances({
address,
networks = [
"eth-mainnet",
"polygon-mainnet",
"base-mainnet",
"arb-mainnet",
"opt-mainnet",
],
apiKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY,
onAddressChange,
}: Props) {
const [byNetwork, setByNetwork] = useState<Record<string, TokenRow[]>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [hasData, setHasData] = useState(false);
const [inputError, setInputError] = useState<string | null>(null);
// Helper function to convert hex to decimal
const hexToDecimal = (hex: string): string => {
if (!hex || hex === '0x') return '0';
return BigInt(hex).toString();
};
// Helper function to convert raw balance to human-readable amount
const formatBalance = (rawBalance: string, decimals: number | undefined): string => {
if (!decimals || decimals === undefined) return rawBalance;
const balance = BigInt(rawBalance);
const divisor = BigInt(10 ** decimals);
const wholePart = balance / divisor;
const fractionalPart = balance % divisor;
if (fractionalPart === 0n) {
return wholePart.toString();
}
// Convert fractional part to decimal string with proper padding
const fractionalStr = fractionalPart.toString().padStart(decimals, '0');
const trimmedFractional = fractionalStr.replace(/0+$/, '');
if (trimmedFractional === '') {
return wholePart.toString();
}
return `${wholePart}.${trimmedFractional}`;
};
// Helper function to validate address input
const isValidAddress = (input: string): boolean => {
if (!input) return false;
// Check if it's a valid ENS name (ends with .eth)
if (input.endsWith('.eth')) {
return true;
}
// Check if it's a valid hex address (0x followed by 40 hex characters)
const hexAddressRegex = /^0x[a-fA-F0-9]{40}$/;
return hexAddressRegex.test(input);
};
// Handle address input changes with validation
const handleAddressChange = (newAddress: string) => {
setInputError(null);
if (newAddress && !isValidAddress(newAddress)) {
setInputError('Please enter a valid 0x address or ENS name (e.g., vitalik.eth)');
return;
}
onAddressChange?.(newAddress);
};
useEffect(() => {
let cancelled = false;
async function run() {
if (!apiKey) {
setError("Missing Alchemy API key. Set NEXT_PUBLIC_ALCHEMY_KEY or pass apiKey prop.");
setLoading(false);
return;
}
try {
// ========================================
// ALCHEMY API #1: eth_resolveName
// Resolves ENS names (like "vitalik.eth") to 0x addresses
// ========================================
let resolvedAddress = address;
if (address.endsWith('.eth')) {
const ensUrl = `https://eth-mainnet.g.alchemy.com/v2/${apiKey}`;
const ensBody = {
jsonrpc: "2.0",
method: "eth_resolveName", // ← ENS resolution API
params: [address],
id: 1
};
const ensRes = await fetch(ensUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ensBody),
});
const ensJson = await ensRes.json();
if (ensJson.result) {
resolvedAddress = ensJson.result;
} else {
throw new Error('Could not resolve ENS name');
}
}
// Define network endpoints
const networkEndpoints: Record<NetworkId, string> = {
"eth-mainnet": `https://eth-mainnet.g.alchemy.com/v2/${apiKey}`,
"polygon-mainnet": `https://polygon-mainnet.g.alchemy.com/v2/${apiKey}`,
"base-mainnet": `https://base-mainnet.g.alchemy.com/v2/${apiKey}`,
"arb-mainnet": `https://arb-mainnet.g.alchemy.com/v2/${apiKey}`,
"opt-mainnet": `https://opt-mainnet.g.alchemy.com/v2/${apiKey}`,
};
const headers = { "content-type": "application/json" };
let allTokens: TokenRow[] = [];
// ========================================
// ALCHEMY API #2: alchemy_getTokenBalances
// Fetches ERC-20 token balances for an address across multiple networks
// ========================================
const networkPromises = networks.map(async (network) => {
const url = networkEndpoints[network];
if (!url) {
console.warn(`No endpoint configured for network: ${network}`);
return [];
}
try {
const body = {
jsonrpc: "2.0",
method: "alchemy_getTokenBalances", // ← Token balances API
params: [resolvedAddress, "erc20"],
id: 1
};
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
console.warn(`Failed to fetch from ${network}:`, res.status);
return [];
}
const json = await res.json();
// Parse the response and convert hex balances to decimal
if (json.result && json.result.tokenBalances) {
const tokenBalances = json.result.tokenBalances;
const networkTokens: TokenRow[] = [];
for (const token of tokenBalances) {
const decimalBalance = hexToDecimal(token.tokenBalance);
// Only include tokens with non-zero balances
if (decimalBalance !== "0") {
networkTokens.push({
network: network,
tokenAddress: token.contractAddress,
tokenBalance: decimalBalance,
symbol: undefined,
name: undefined,
decimals: undefined
});
}
}
return networkTokens;
}
} catch (error) {
console.warn(`Error fetching from ${network}:`, error);
}
return [];
});
// Wait for all network requests to complete
const networkResults = await Promise.all(networkPromises);
// Flatten all tokens into a single array
allTokens = networkResults.flat();
// ========================================
// ALCHEMY API #3: alchemy_getTokenMetadata
// Fetches token metadata (name, symbol, decimals) for each token contract
// ========================================
if (allTokens.length > 0) {
// Group tokens by network for metadata fetching
const tokensByNetwork: Record<string, TokenRow[]> = {};
allTokens.forEach(token => {
if (!tokensByNetwork[token.network]) {
tokensByNetwork[token.network] = [];
}
tokensByNetwork[token.network].push(token);
});
// Fetch metadata for each network
const metadataPromises = Object.entries(tokensByNetwork).map(async ([network, tokens]) => {
const url = networkEndpoints[network as NetworkId];
if (!url) return;
const tokenAddresses = tokens.map(t => t.tokenAddress).filter(Boolean);
for (const address of tokenAddresses) {
try {
const metadataBody = {
jsonrpc: "2.0",
method: "alchemy_getTokenMetadata", // ← Token metadata API
params: [address],
id: Math.floor(Math.random() * 10000)
};
const metadataRes = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(metadataBody),
});
if (metadataRes.ok) {
const metadataJson = await metadataRes.json();
if (metadataJson.result) {
// Find and update the token with metadata
const token = tokens.find(t => t.tokenAddress?.toLowerCase() === address.toLowerCase());
if (token) {
token.symbol = metadataJson.result.symbol;
token.name = metadataJson.result.name;
token.decimals = metadataJson.result.decimals;
}
}
}
} catch (err) {
console.warn(`Failed to fetch metadata for ${address} on ${network}:`, err);
}
}
});
// Wait for all metadata requests to complete
await Promise.all(metadataPromises);
}
// Group by network
const map: Record<string, TokenRow[]> = {};
for (const t of allTokens) {
const net = t.network || "unknown";
if (!map[net]) map[net] = [];
map[net].push(t);
}
if (!cancelled) {
setByNetwork(map);
setError(null);
setHasData(Object.keys(map).length > 0);
}
} catch (e: unknown) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Unknown error");
}
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, [address, apiKey]);
if (loading) return <div className="p-4 text-sm">Loading balances…</div>;
if (error) return <div className="p-4 text-sm text-red-600">Error: {error}</div>;
const networkKeys = Object.keys(byNetwork).sort();
return (
<div className="p-4 max-w-4xl space-y-4">
<h2 className="text-lg font-semibold">Cross-chain balances</h2>
<div className="space-y-2">
<label htmlFor="address-input" className="block text-sm font-medium text-gray-700">
Wallet Address
</label>
<div className="flex gap-2">
<div className="flex-1">
<input
id="address-input"
type="text"
value={address}
onChange={(e) => handleAddressChange(e.target.value)}
placeholder="Enter 0x address or ENS name (e.g., vitalik.eth)"
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
inputError
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500'
}`}
/>
{inputError && (
<p className="mt-1 text-sm text-red-600">{inputError}</p>
)}
</div>
<button
onClick={() => handleAddressChange(address)}
disabled={!!inputError || !address}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Load
</button>
</div>
</div>
{!hasData ? (
<div className="text-sm text-gray-600">No tokens found.</div>
) : (
<div className="space-y-6">
{networkKeys.map((net) => {
const tokens = byNetwork[net] || [];
return (
<section key={net} className="border rounded-xl p-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">{net}</h3>
<span className="text-xs text-gray-500">
{tokens.length} tokens
</span>
</div>
<ul className="mt-2 divide-y text-sm">
{tokens.map((t, i) => {
const tokenName = t.symbol || t.name || (t.tokenAddress ? `Token ${t.tokenAddress.slice(0, 6)}...` : "Native Token");
const rawBalance = t.tokenBalance || "0";
const formattedBalance = formatBalance(rawBalance, t.decimals);
const displayName = t.symbol && t.name ? `${t.symbol} (${t.name})` : tokenName;
return (
<li key={`${t.tokenAddress ?? "native"}-${i}`} className="py-2">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{displayName}
</div>
{t.tokenAddress && (
<div className="text-xs text-gray-500 truncate">
{t.tokenAddress}
</div>
)}
</div>
<div className="ml-4 text-right">
<div className="font-mono text-sm font-semibold">
{formattedBalance}
</div>
{t.symbol && (
<div className="text-xs text-gray-500">
{t.symbol}
</div>
)}
</div>
</div>
</li>
);
})}
</ul>
</section>
);
})}
</div>
)}
<p className="text-xs text-gray-500">
Tip: For human-readable amounts and prices, join with token metadata and a prices source later.
</p>
</div>
);
}- Paginate results — if
pageKeyis returned in the response, repeat the call with it to fetch the next page. - Use per-token decimals — read
decimalsfromalchemy_getTokenMetadataper contract rather than assuming 18; the Token API returnsdecimals,symbol, and more. - Add NFTs — pair with
getNFTsfor a full wallet portfolio view that includes non-fungible tokens.