> ## 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.

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](https://www.helius.dev/docs/api-reference/das/getassetsbyowner.md) call, load transaction history with the Helius-exclusive [`getTransactionsForAddress`](https://www.helius.dev/docs/rpc/gettransactionsforaddress), and stream new activity live over [`transactionSubscribe`](https://www.helius.dev/docs/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.

<form>
  Your API Key

  Wallet Address
</form>

Your API key is used only in your browser to call Helius directly. It is never sent anywhere else.

***

## Build it as a Next.js app

> [card] [Grab the full app on GitHub](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`.

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.

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

**1. 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
```

**2. 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 } });
  }
}
```

**3. 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: 1,
              },
            ],
          })
        );
      });

      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",
    },
  });
}
```

**4. 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>
  );
}
```

**5. 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.

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

> [card] [getAssetsByOwner](https://www.helius.dev/docs/api-reference/das/getassetsbyowner.md)
>
> All display options, pagination, and the full asset response shape.

> [card] [getTransactionsForAddress](https://www.helius.dev/docs/rpc/gettransactionsforaddress.md)
>
> Advanced filtering by time, slot, token, direction, and amount.

> [card] [transactionSubscribe](https://www.helius.dev/docs/rpc/websocket/transaction-subscribe.md)
>
> Filter options and payload shape for the live WebSocket feed.

> [card] [Deploy your own program](https://www.helius.dev/docs/quickstart/deploy-program.md)
>
> Next track: ship a Solana program to devnet through Helius.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.