> ## Documentation Index
> Fetch the complete documentation index at: https://www.helius.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Portfolio Tracker on Solana

> Build a Solana portfolio tracker that surfaces any wallet's complete holdings alongside its live and historical on-chain activity.

export const PortfolioTrackerDemo = () => {
  const [apiKey, setApiKey] = useState('');
  const [ownerAddress, setOwnerAddress] = useState('86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [portfolio, setPortfolio] = useState(null);
  const [history, setHistory] = useState(null);
  const [live, setLive] = useState([]);
  const [liveStatus, setLiveStatus] = useState('idle');
  const wsRef = useRef(null);
  const rpc = async (method, params) => {
    const res = await fetch(`https://mainnet.helius-rpc.com/?api-key=${apiKey}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        jsonrpc: '2.0',
        id: 'quickstart',
        method,
        params
      })
    });
    const json = await res.json();
    if (json.error) throw new Error(json.error.message || 'RPC error');
    return json.result;
  };
  const stopLive = () => {
    if (wsRef.current) {
      try {
        wsRef.current.close();
      } catch (e) {}
      wsRef.current = null;
    }
    setLiveStatus('idle');
  };
  useEffect(() => stopLive, []);
  const handleLoad = async e => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    setPortfolio(null);
    setHistory(null);
    stopLive();
    setLive([]);
    try {
      const assets = await rpc('getAssetsByOwner', {
        ownerAddress,
        page: 1,
        limit: 20,
        displayOptions: {
          showFungible: true,
          showNativeBalance: true
        }
      });
      const txns = await rpc('getTransactionsForAddress', [ownerAddress, {
        transactionDetails: 'signatures',
        sortOrder: 'desc',
        limit: 10,
        filters: {
          tokenAccounts: 'balanceChanged'
        }
      }]);
      const items = assets.items || [];
      const tokens = items.filter(i => (i.interface || '').includes('Fungible'));
      const nfts = items.filter(i => !(i.interface || '').includes('Fungible'));
      setPortfolio({
        sol: (assets.nativeBalance && assets.nativeBalance.lamports ? assets.nativeBalance.lamports : 0) / 1e9,
        tokenCount: tokens.length,
        nftCount: nfts.length,
        tokens: tokens.slice(0, 6),
        nfts: nfts.slice(0, 4)
      });
      setHistory(txns.data || []);
    } catch (err) {
      const msg = err.message || '';
      setError((/too big|too large/i).test(msg) ? 'This wallet holds too many assets to load in the demo. Try a wallet with fewer tokens and NFTs, or lower the limit in getAssetsByOwner.' : msg || 'Failed to load wallet');
    } finally {
      setLoading(false);
    }
  };
  const startLive = () => {
    if (!apiKey) {
      setError('Enter your API key first');
      return;
    }
    setLiveStatus('connecting');
    const ws = new WebSocket(`wss://mainnet.helius-rpc.com/?api-key=${apiKey}`);
    wsRef.current = ws;
    ws.onopen = () => {
      setLiveStatus('live');
      ws.send(JSON.stringify({
        jsonrpc: '2.0',
        id: 1,
        method: 'transactionSubscribe',
        params: [{
          accountInclude: [ownerAddress],
          tokenAccounts: 'balanceChanged'
        }, {
          commitment: 'confirmed',
          encoding: 'jsonParsed',
          transactionDetails: 'signatures',
          maxSupportedTransactionVersion: 0
        }]
      }));
    };
    ws.onmessage = event => {
      const msg = JSON.parse(event.data);
      const sig = msg && msg.params && msg.params.result && msg.params.result.signature;
      if (sig) setLive(prev => [{
        sig,
        at: new Date().toLocaleTimeString()
      }, ...prev].slice(0, 8));
    };
    ws.onerror = () => setLiveStatus('error');
    ws.onclose = () => {
      if (wsRef.current === ws) setLiveStatus('idle');
    };
  };
  const short = s => s ? `${s.slice(0, 6)}...${s.slice(-6)}` : '';
  const btnClass = loading ? 'px-4 py-2 font-medium rounded-full bg-gray-300 dark:bg-gray-700 cursor-not-allowed' : 'px-4 py-2 font-medium rounded-full bg-primary hover:bg-primary/80 text-white';
  const dotClass = liveStatus === 'live' ? 'inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse' : liveStatus === 'error' ? 'inline-block w-2 h-2 rounded-full bg-red-500' : 'inline-block w-2 h-2 rounded-full bg-yellow-500';
  return <div className="p-4 border dark:border-zinc-950/80 rounded-xl bg-white dark:bg-zinc-950/80 shadow-sm">
      <form onSubmit={handleLoad} className="space-y-4">
        <div>
          <label className="block text-sm text-zinc-950/70 dark:text-white/70 mb-1">Your API Key</label>
          <input type="text" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="Enter your API key" className="w-full p-2 border rounded dark:bg-zinc-900 dark:border-zinc-700" required />
        </div>
        <div>
          <label className="block text-sm text-zinc-950/70 dark:text-white/70 mb-1">Wallet Address</label>
          <input type="text" value={ownerAddress} onChange={e => setOwnerAddress(e.target.value)} placeholder="Solana wallet address" className="w-full p-2 border rounded dark:bg-zinc-900 dark:border-zinc-700" required />
        </div>
        <div className="flex gap-2">
          <button type="submit" disabled={loading} className={btnClass}>
            {loading ? 'Loading...' : 'Load Wallet'}
          </button>
          {liveStatus === 'live' || liveStatus === 'connecting' ? <button type="button" onClick={stopLive} className="px-4 py-2 font-medium rounded-full border border-zinc-300 dark:border-zinc-700">Stop live feed</button> : <button type="button" onClick={startLive} className="px-4 py-2 font-medium rounded-full border border-zinc-300 dark:border-zinc-700">Go live</button>}
        </div>
      </form>
      <p className="mt-3 text-xs text-zinc-500 dark:text-zinc-400">
        Your API key is used only in your browser to call Helius directly. It is never sent anywhere else.
      </p>
      {error ? <div className="mt-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded text-red-700 dark:text-red-300">
          <strong>Error:</strong> {error}
        </div> : null}
      {portfolio ? <div className="mt-6 space-y-4">
          <div className="grid grid-cols-2 gap-3 text-center">
            <div className="p-3 rounded-lg border border-zinc-200 dark:border-zinc-800">
              <div className="text-xl font-semibold text-zinc-950 dark:text-white">{portfolio.sol.toFixed(3)}</div>
              <div className="text-xs text-zinc-500 dark:text-zinc-400">SOL</div>
            </div>
            <div className="p-3 rounded-lg border border-zinc-200 dark:border-zinc-800">
              <div className="text-xl font-semibold text-zinc-950 dark:text-white">{portfolio.nftCount}</div>
              <div className="text-xs text-zinc-500 dark:text-zinc-400">NFTs</div>
            </div>
          </div>
          {portfolio.tokens.length > 0 ? <div>
              <h4 className="text-sm font-semibold mb-2 text-zinc-950 dark:text-white">Tokens</h4>
              <ul className="space-y-1 list-none pl-0 m-0">
                {portfolio.tokens.map(t => {
    const info = t.token_info || ({});
    const amount = (info.balance || 0) / 10 ** (info.decimals || 0);
    const name = t.content && t.content.metadata && t.content.metadata.name || info.symbol || 'Unknown token';
    const img = t.content && t.content.links ? t.content.links.image : '';
    return <li key={t.id} className="flex items-center gap-3 text-sm my-0">
                      <img src={img || ''} alt="" className="w-7 h-7 rounded-full bg-zinc-200 dark:bg-zinc-800 my-0 shrink-0" />
                      <span className="flex-1 truncate text-zinc-800 dark:text-zinc-200">{name}</span>
                      <span className="font-mono text-zinc-600 dark:text-zinc-400">{amount.toLocaleString(undefined, {
      maximumFractionDigits: 4
    })}</span>
                    </li>;
  })}
              </ul>
            </div> : null}
          {portfolio.nfts.length > 0 ? <div className="grid grid-cols-4 gap-2">
              {portfolio.nfts.map(nft => <img key={nft.id} src={nft.content && nft.content.files && nft.content.files[0] ? nft.content.files[0].uri : ''} alt={nft.content && nft.content.metadata ? nft.content.metadata.name : 'NFT'} className="rounded-lg border border-zinc-200 dark:border-zinc-800 aspect-square object-cover my-0" />)}
            </div> : null}
        </div> : null}
      {history && history.length > 0 ? <div className="mt-6">
          <h4 className="text-sm font-semibold mb-2 text-zinc-950 dark:text-white">Recent activity</h4>
          <ul className="space-y-1 text-sm font-mono list-none pl-0 m-0">
            {history.map(tx => <li key={tx.signature} className="flex items-center justify-between text-zinc-600 dark:text-zinc-400 my-0">
                <span className="flex items-center gap-2">
                  <span className={tx.err ? 'inline-block w-2 h-2 rounded-full bg-red-500' : 'inline-block w-2 h-2 rounded-full bg-green-500'} />
                  <a href={'https://orbmarkets.io/tx/' + tx.signature} target="_blank" rel="noreferrer" className="hover:underline">{short(tx.signature)}</a>
                </span>
                <span className="text-xs">{tx.blockTime ? new Date(tx.blockTime * 1000).toLocaleDateString() : ''}</span>
              </li>)}
          </ul>
        </div> : null}
      {liveStatus !== 'idle' || live.length > 0 ? <div className="mt-6">
          <h4 className="text-sm font-semibold mb-2 text-zinc-950 dark:text-white flex items-center gap-2">
            Live feed
            <span className={dotClass} />
            <span className="text-xs font-normal text-zinc-500 dark:text-zinc-400">{liveStatus}</span>
          </h4>
          {live.length === 0 ? <p className="text-sm text-zinc-500 dark:text-zinc-400">Waiting for the next transaction that touches this wallet.</p> : <ul className="space-y-1 text-sm font-mono list-none pl-0 m-0">
              {live.map((item, i) => <li key={i} className="flex items-center justify-between text-zinc-600 dark:text-zinc-400 my-0">
                  <span className="flex items-center gap-2">
                    <span className="inline-block w-2 h-2 rounded-full bg-green-500" />
                    <a href={'https://orbmarkets.io/tx/' + item.sig} target="_blank" rel="noreferrer" className="hover:underline">{short(item.sig)}</a>
                  </span>
                  <span className="text-xs">{item.at}</span>
                </li>)}
            </ul>}
        </div> : null}
    </div>;
};

In this guide you'll build a dashboard that shows what any Solana address holds and what it's doing right now. You'll pull its full portfolio of tokens, NFTs, and SOL in a single [DAS](/api-reference/das/getassetsbyowner) call, load transaction history with the Helius-exclusive [`getTransactionsForAddress`](/rpc/gettransactionsforaddress), and stream new activity live over [`transactionSubscribe`](/rpc/websocket/transaction-subscribe). You'll try it first as an embedded demo, then build it as a full Next.js app.

## Try it live

Paste your API key and any wallet address to see the three pieces working together: portfolio, history, and a live feed. This is the same data your app will render.

<PortfolioTrackerDemo />

***

## Build it as a Next.js app

<Card title="Grab the full app on GitHub" icon="github" href="https://github.com/helius-labs/quickstart-portfolio-tracker">
  Clone the complete starter — the finished Next.js app from this walkthrough, ready to `npm install && npm run dev`.
</Card>

The widget above calls Helius directly from the browser, which exposes your API key. In a real app you keep the key on the server. This walkthrough uses the Next.js App Router with two route handlers so **your key never reaches the client**: one proxies RPC calls, the other relays the WebSocket feed as Server-Sent Events.

<Info>
  **Prerequisites:** Node.js 18+ and a [Helius API key](https://dashboard.helius.dev/api-keys).
</Info>

<Steps>
  <Step title="Scaffold the project">
    ```bash theme={"system"}
    npx create-next-app@latest portfolio-tracker --ts --app --tailwind --no-src-dir --eslint --yes
    cd portfolio-tracker
    npm install helius-sdk ws
    npm install --save-dev @types/ws
    ```

    You'll use the [Helius SDK](https://github.com/helius-labs/helius-sdk) for the RPC calls and `ws` for the live WebSocket feed.

    Add your key to `.env.local`:

    ```bash .env.local theme={"system"}
    HELIUS_API_KEY=YOUR_API_KEY
    ```
  </Step>

  <Step title="Proxy RPC calls through a route handler">
    This keeps your API key server-side. The client posts a method and params here, and the handler runs them through the Helius SDK, which attaches your key and builds the request for you.

    ```typescript app/api/helius/route.ts theme={"system"}
    import { NextRequest, NextResponse } from "next/server";
    import { createHelius } from "helius-sdk";

    export const runtime = "nodejs";

    const helius = createHelius({
      apiKey: process.env.HELIUS_API_KEY!,
      network: "mainnet",
    });

    export async function POST(req: NextRequest) {
      const { method, params } = await req.json();
      try {
        // The two Helius calls that power this app: the full portfolio and its history.
        const result =
          method === "getAssetsByOwner"
            ? await helius.getAssetsByOwner(params)
            : method === "getTransactionsForAddress"
            ? await helius.getTransactionsForAddress(params)
            : undefined;

        if (result === undefined) {
          return NextResponse.json(
            { error: { message: `Unsupported method: ${method}` } },
            { status: 400 }
          );
        }
        return NextResponse.json({ result });
      } catch (e) {
        return NextResponse.json({ error: { message: (e as Error).message } });
      }
    }
    ```
  </Step>

  <Step title="Relay the live feed over Server-Sent Events">
    The browser can't open a Helius WebSocket without the key. Instead, open the WebSocket on the server and stream signatures to the client with SSE.

    ```typescript app/api/stream/route.ts [expandable] theme={"system"}
    import { NextRequest } from "next/server";
    import WebSocket from "ws";

    export const runtime = "nodejs";

    export async function GET(req: NextRequest) {
      const address = req.nextUrl.searchParams.get("address");
      if (!address) return new Response("Missing address", { status: 400 });

      const stream = new ReadableStream({
        start(controller) {
          const encoder = new TextEncoder();
          const send = (data: unknown) =>
            controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));

          const ws = new WebSocket(
            `wss://mainnet.helius-rpc.com/?api-key=${process.env.HELIUS_API_KEY}`
          );

          ws.on("open", () => {
            ws.send(
              JSON.stringify({
                jsonrpc: "2.0",
                id: 1,
                method: "transactionSubscribe",
                params: [
                  // tokenAccounts: "balanceChanged" also matches the wallet's token accounts,
                  // so incoming SPL transfers (which touch an ATA, not the wallet pubkey) show up.
                  { accountInclude: [address], tokenAccounts: "balanceChanged" },
                  {
                    commitment: "confirmed",
                    encoding: "jsonParsed",
                    transactionDetails: "signatures",
                    maxSupportedTransactionVersion: 0,
                  },
                ],
              })
            );
          });

          ws.on("message", (raw) => {
            const msg = JSON.parse(raw.toString());
            const sig = msg?.params?.result?.signature;
            if (sig) send({ signature: sig, at: Date.now() });
          });

          // Tear down the upstream socket when the client disconnects.
          req.signal.addEventListener("abort", () => {
            ws.close();
            controller.close();
          });
        },
      });

      return new Response(stream, {
        headers: {
          "Content-Type": "text/event-stream",
          "Cache-Control": "no-cache",
          Connection: "keep-alive",
        },
      });
    }
    ```
  </Step>

  <Step title="Render the dashboard">
    The page loads the portfolio and history through the proxy, then subscribes to the SSE feed for live updates.

    ```tsx app/page.tsx [expandable] theme={"system"}
    "use client";

    import { useEffect, useState } from "react";

    const rpc = async (method: string, params: unknown) => {
      const res = await fetch("/api/helius", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ jsonrpc: "2.0", id: "1", method, params }),
      });
      const json = await res.json();
      if (json.error) throw new Error(json.error.message);
      return json.result;
    };

    export default function Home() {
      const [address, setAddress] = useState(
        "86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY"
      );
      const [portfolio, setPortfolio] = useState<any>(null);
      const [history, setHistory] = useState<any[]>([]);
      const [live, setLive] = useState<any[]>([]);
      const [streamAddress, setStreamAddress] = useState<string | null>(null);
      const [error, setError] = useState<string | null>(null);

      const load = async () => {
        setError(null);
        setLive([]);
        setStreamAddress(address); // point the live feed at the wallet you just loaded
        try {
          const [assets, txns] = await Promise.all([
            rpc("getAssetsByOwner", {
              ownerAddress: address,
              page: 1,
              limit: 20,
              displayOptions: { showFungible: true, showNativeBalance: true },
            }),
            rpc("getTransactionsForAddress", [
              address,
              { transactionDetails: "signatures", sortOrder: "desc", limit: 10, filters: { tokenAccounts: "balanceChanged" } },
            ]),
          ]);
          const items = assets.items ?? [];
          setPortfolio({
            sol: (assets.nativeBalance?.lamports ?? 0) / 1e9,
            tokens: items.filter((i: any) => `${i.interface}`.includes("Fungible")),
            nfts: items.filter((i: any) => !`${i.interface}`.includes("Fungible")),
          });
          setHistory(txns.data ?? []);
        } catch (e: any) {
          setError(e.message);
        }
      };

      // Open the live feed only after Load runs, and reopen only when the loaded wallet changes
      // (not on every keystroke in the input).
      useEffect(() => {
        if (!streamAddress) return;
        const source = new EventSource(`/api/stream?address=${streamAddress}`);
        source.onmessage = (e) =>
          setLive((prev) => [JSON.parse(e.data), ...prev].slice(0, 10));
        return () => source.close();
      }, [streamAddress]);

      return (
        <main className="max-w-2xl mx-auto p-8 space-y-6">
          <div className="flex gap-2">
            <input
              className="flex-1 border rounded p-2"
              value={address}
              onChange={(e) => setAddress(e.target.value)}
            />
            <button className="px-4 rounded bg-black text-white" onClick={load}>
              Load
            </button>
          </div>

          {error && <p className="text-red-600">{error}</p>}

          {portfolio && (
            <section className="space-y-6">
              <Stat label="SOL balance" value={portfolio.sol.toFixed(3)} />

              {portfolio.tokens.length > 0 && (
                <div>
                  <h2 className="font-semibold mb-2">Tokens ({portfolio.tokens.length})</h2>
                  <ul className="space-y-1 list-none pl-0">
                    {portfolio.tokens.map((t: any) => {
                      const info = t.token_info ?? {};
                      const amount = (info.balance ?? 0) / 10 ** (info.decimals ?? 0);
                      return (
                        <li key={t.id} className="flex items-center gap-3">
                          <img
                            src={t.content?.links?.image ?? ""}
                            alt=""
                            className="w-8 h-8 rounded-full bg-zinc-200 shrink-0"
                          />
                          <span className="flex-1 truncate">
                            {t.content?.metadata?.name ?? info.symbol ?? "Unknown token"}
                          </span>
                          <span className="font-mono text-sm">
                            {amount.toLocaleString(undefined, { maximumFractionDigits: 4 })}
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              )}

              {portfolio.nfts.length > 0 && (
                <div>
                  <h2 className="font-semibold mb-2">NFTs ({portfolio.nfts.length})</h2>
                  <div className="grid grid-cols-3 gap-3">
                    {portfolio.nfts.map((n: any) => (
                      <img
                        key={n.id}
                        src={n.content?.files?.[0]?.uri ?? n.content?.links?.image ?? ""}
                        alt={n.content?.metadata?.name ?? "NFT"}
                        className="rounded-lg aspect-square object-cover border"
                      />
                    ))}
                  </div>
                </div>
              )}
            </section>
          )}

          {history.length > 0 && (
            <section>
              <h2 className="font-semibold mb-2">History</h2>
              <ul className="font-mono text-sm space-y-1 list-none pl-0">
                {history.map((tx) => (
                  <li key={tx.signature}>
                    {tx.err ? "❌" : "✅"}{" "}
                    <a
                      href={`https://orbmarkets.io/tx/${tx.signature}`}
                      target="_blank"
                      rel="noreferrer"
                      className="underline hover:text-black"
                    >
                      {tx.signature.slice(0, 16)}…
                    </a>
                  </li>
                ))}
              </ul>
            </section>
          )}

          {live.length > 0 && (
            <section>
              <h2 className="font-semibold mb-2">Live ⚡</h2>
              <ul className="font-mono text-sm space-y-1 list-none pl-0">
                {live.map((e, i) => (
                  <li key={i}>
                    <a
                      href={`https://orbmarkets.io/tx/${e.signature}`}
                      target="_blank"
                      rel="noreferrer"
                      className="underline hover:text-black"
                    >
                      {e.signature.slice(0, 16)}…
                    </a>
                  </li>
                ))}
              </ul>
            </section>
          )}
        </main>
      );
    }

    function Stat({ label, value }: { label: string; value: string | number }) {
      return (
        <div className="border rounded-lg p-3">
          <div className="text-xl font-semibold">{value}</div>
          <div className="text-xs text-zinc-500">{label}</div>
        </div>
      );
    }
    ```
  </Step>

  <Step title="Run it">
    ```bash theme={"system"}
    npm run dev
    ```

    Open [http://localhost:3000](http://localhost:3000), paste a wallet address, and hit **Load**. You'll see the portfolio and history immediately, and the live feed fills in as new transactions touch the wallet.
  </Step>
</Steps>

## What's happening

### One DAS call for the whole portfolio

`getAssetsByOwner` with `showFungible: true` and `showNativeBalance: true` returns fungible tokens, NFTs (standard **and** compressed), and the native SOL balance in a single response, so you don't need separate `getBalance` or `getTokenAccountsByOwner` calls. Split `items` by `interface` to group tokens versus NFTs.

Every item carries its own metadata, so the same call powers both lists. Use `content.metadata.name` for the name and `content.links.image` for the logo or artwork. Fungible tokens also include a `token_info` object with the raw `balance`, `decimals`, and `symbol` — divide `balance` by `10 ** decimals` for the human-readable amount. Not every token ships a name or logo, so fall back gracefully when they're missing.

### Complete history in one request

`getTransactionsForAddress` returns transaction history for the wallet, and with `filters.tokenAccounts: "balanceChanged"` it also includes transfers on the wallet's associated token accounts, the activity a plain `getSignaturesForAddress` would miss. Use `transactionDetails: "full"` to get complete transaction data instead of just signatures.

### Real-time updates without polling

`transactionSubscribe` pushes a message every time a transaction touches the wallet, so you never poll. Adding `tokenAccounts: "balanceChanged"` to the filter also matches the wallet's token accounts, so incoming SPL transfers appear in the feed — the same activity the history call counts. Relaying the subscription through a server-side route handler keeps your API key off the client.

## Next steps

<CardGroup cols={2}>
  <Card title="getAssetsByOwner" icon="images" href="/api-reference/das/getassetsbyowner">
    All display options, pagination, and the full asset response shape.
  </Card>

  <Card title="getTransactionsForAddress" icon="clock-rotate-left" href="/rpc/gettransactionsforaddress">
    Advanced filtering by time, slot, token, direction, and amount.
  </Card>

  <Card title="transactionSubscribe" icon="bolt" href="/rpc/websocket/transaction-subscribe">
    Filter options and payload shape for the live WebSocket feed.
  </Card>

  <Card title="Deploy your own program" icon="rocket" href="/quickstart/deploy-program">
    Next track: ship a Solana program to devnet through Helius.
  </Card>
</CardGroup>
