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

# Build a token page

> Show a token's price, stats, holders, security and live trades, kept current by streams.

A token page answers three questions at a glance: what is it worth, who holds it, and what is trading now. This recipe fills each section from one REST read, then keeps the page live with two subscriptions.

| Section | Source |
| - | - |
| Header: name, symbol, price, market cap, liquidity | Token object |
| Stats: change and volume over 5m to 24h | Token object `stats` |
| Holders and risk: top 10, dev, snipers, bundlers, insiders | Token object `holders` |
| Security: mint, freeze, LP burnt, transfer fee | Token object `security` |
| Launchpad status: curve progress, graduation | Token object `bondingCurvePct`, `graduatedAt` |
| Recent trades | `GET /v1/tokens/{chain}/{address}/trades` |
| Live updates | `spot.tokens` and `spot.trades` |

## 1. Subscribe first

Open the stream and subscribe before you read, so no change falls between the read and the subscription. Wait for both acks: an ack always arrives before its subscription's first update. See [REST reads and their streams](/concepts/rest-and-streams).

```typescript theme={null}
import WebSocket from "ws";

const API = "https://{{API_HOST}}/v1";
const headers = { Authorization: `Bearer ${process.env.API_KEY}` };
const chain = "solana";
const address = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263";

const socket = new WebSocket("wss://{{API_HOST}}/v1/stream", { headers });
const buffered: any[] = [];
let ready = false;

socket.on("error", (error) => console.error("stream error:", error.message));

const subscribed = new Promise<void>((resolve) => {
  let acks = 0;
  socket.on("message", (raw) => {
    const frame = JSON.parse(raw.toString());
    if (frame.type === "ack" && ++acks === 2) resolve();
    if (frame.type !== "update") return;
    if (ready) apply(frame);
    else buffered.push(frame);
  });
});

socket.on("open", () => {
  socket.send(JSON.stringify({ action: "subscribe", channel: "spot.tokens", filters: { chain, tokens: [address] } }));
  socket.send(JSON.stringify({ action: "subscribe", channel: "spot.trades", filters: { chain, token: address } }));
});

await subscribed;
```

## 2. Read the snapshot

Read the token object and the latest trades in parallel:

```typescript theme={null}
async function getJson(url: string) {
  const response = await fetch(url, { headers });
  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

const [token, page] = await Promise.all([
  getJson(`${API}/tokens/${chain}/${address}`),
  getJson(`${API}/tokens/${chain}/${address}/trades?limit=50`),
]);

// A token the API does not know returns 404. Show a "token not found" state rather than retrying.
if (token === null) throw new Error("Token not found");

const state = { token, trades: page.trades as any[] };
for (const frame of buffered) apply(frame);
ready = true;
render(state);
```

## 3. Apply updates

```typescript theme={null}
function apply(frame: { channel: string; data: any }) {
  if (frame.channel === "spot.tokens") {
    if (frame.data.updatedAt >= state.token.updatedAt) state.token = frame.data;
  }
  if (frame.channel === "spot.trades") {
    if (!state.trades.some((trade) => trade.id === frame.data.id)) {
      state.trades = [frame.data, ...state.trades].slice(0, 50);
    }
  }
  render(state);
}
```

Trades from a `token` subscription leave out `flags` and `platforms`. To show wallet badges on live trades, re-read the trades endpoint periodically, and keep the REST copy of a trade when you merge.

## 4. Render the sections

### Header

```typescript theme={null}
const price = Number(state.token.price.usd);            // display only; keep the string for maths
const change24h = state.token.stats["24h"].priceChangePct; // "-3.4" means -3.4%
```

Show the `native` leg next to USD for traders who think in SOL, ETH or BNB.

### Holders and risk

| Show | Field | Worth a warning when |
| - | - | - |
| Top 10 share | `holders.top10Pct` | High concentration, for example above 50 |
| Creator share | `holders.devPct` | The creator still holds a large share |
| Snipers | `holders.sniperPct`, `holders.sniperCount` | Snipers hold a large share |
| Bundlers | `holders.bundlerPct`, `holders.bundlerCount` | Bundled buys hold a large share |
| Insiders | `holders.insiderPct`, `holders.insiderCount` | Very early buyers hold a large share |
| Fresh wallets | `holders.freshWalletPct` | New wallets hold a large share |

The thresholds are yours to choose. Every `Pct` field is on a 0–100 scale. The groups are defined in the [glossary](/resources/glossary#holder-groups).

### Security

| Show | Field | Safe reading |
| - | - | - |
| Mint revoked | `security.mintDisabled` | `true` |
| Transfers can be blocked (freeze authority, blacklist or pause) | `security.transferBlockable` | `false` |
| Liquidity burnt or locked | `security.lpBurntPct` | Close to `100` |
| Transfer fee | `security.transferFeeBps` | `0`. `null` means the contract has not been checked yet. |

### Launchpad status

* While `graduatedAt` is `null` and `bondingCurvePct` is set, show a progress bar from `bondingCurvePct`.
* Once `graduatedAt` is set, show where it went: `graduatedToDex`.

### Links

`metadata.website`, `metadata.twitter`, `metadata.telegram` and `metadata.discord` can each be `null`. Hide the ones that are.

## Next

* Add a chart: [Render a live chart](/recipes/live-chart).
* Handle reconnects: [Connecting to streams](/streams/overview#when-the-server-closes-the-connection).


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