Skip to content
DocsDevelopersFrontend integration

Frontend Integration

The indexer is a read API your frontend queries directly — Supabase PostgREST for queries and Supabase Realtime for live updates. There is no application server in front of it doing escaping or shaping for you. Much of the data it serves is user- and contract-supplied on-chain text, so treating it safely is the integrator's job.

The TypeScript SDK supplies typed, precision-preserving indexer reads, profile joins and realtime reconciliation. Applications still own safe rendering and their wallet boundary.

This page is the rendering and operating checklist. For the catalogue of ds_* tables, query patterns, and TypeScript row types, see The Indexer.

Defense in depth

The indexer already filters trust levels, validates URL schemes, and strips control characters at index time. Do all of the same checks again on the frontend anyway. A validation bug, a schema change shipped before your frontend updates, or a pre-validation row left in the database can each let something through.

Preserve numeric precision before parsing

Raw PostgREST NUMERIC and BIGINT values can arrive as JSON numbers. Select large numeric columns with ::text before parsing, or use the SDK's typed projections, which already do this. An unsafe number cannot be repaired by converting it to a string or BigInt afterwards.

For explicit low-level reads, reject invalid amounts rather than replacing an unknown balance with zero:

function toBig(value: unknown): bigint {
  if (typeof value === "string" && /^(0|[1-9]\d*)$/.test(value)) return BigInt(value);
  if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return BigInt(value);
  throw new Error("Expected an exact nonnegative integer.");
}

Preserve nullable fields as unknown when the schema allows them. Disable decisions that require an unavailable amount and show a missing-data state. The SDK's parseTokenAmount rejects excess fractional precision; the CLI expects decimal strings for token base units in JSON.

Addresses are lowercase

Every address in the database is stored lowercase — the indexer normalizes them at write time. Wallet providers (MetaMask and friends) return EIP-55 mixed-case checksum addresses. A direct comparison silently fails:

// BUG: connectedAddress is mixed-case, dao.avatar is lowercase → never matches
if (connectedAddress === dao.avatar) showTreasuryControls();
 
// CORRECT: normalize both sides
if (connectedAddress.toLowerCase() === dao.avatar) showTreasuryControls();
 
function addressEq(a: string, b: string): boolean {
  return a.toLowerCase() === b.toLowerCase();
}

Lowercase both sides for every comparison, and lowercase any address you use as a Map key, a React key, or a query filter value.

Timestamps are UTC ISO 8601

All timestamp columns are TIMESTAMPTZ and come back as UTC ISO 8601 strings like 2026-03-25T14:30:00.000Z. The JavaScript Date constructor parses these correctly — pass the string straight through. Never strip the trailing Z; doing so reinterprets a UTC instant as local time and shifts it by your timezone offset.

const created = new Date(record.created_at); // correct
const label = created.toLocaleString(); // render in the viewer's locale

Proposal and poll lifecycle status is derived client-side — there is no stored status column. The indexer pre-calculates the boundary timestamps (voting_starts, voting_ends, grace_ends, expiration); compute status from them rather than doing your own period math. See Proposal lifecycle for the state machine.

type ProposalStatus =
  | "Submitted" | "Voting" | "Grace" | "Ready"
  | "Processed" | "Defeated" | "Expired" | "Cancelled";
 
function deriveStatus(p: ProposalRow, now = new Date()): ProposalStatus {
  if (p.cancelled) return "Cancelled";
  if (p.processed) return p.passed ? "Processed" : "Defeated";
  if (!p.sponsored) return "Submitted";
  if (p.voting_ends && now < new Date(p.voting_ends)) return "Voting";
  if (p.grace_ends && now < new Date(p.grace_ends)) return "Grace";
  if (p.expiration && now > new Date(p.expiration)) return "Expired";
  return "Ready";
}

Poster content is untrusted — prevent XSS

Never pass Poster fields to dangerouslySetInnerHTML

These fields hold arbitrary text written by any wallet (the Poster contract is permissionless): ds_daos.name / description / avatar_img, ds_proposals.details / proposal_data, ds_navigators.name / description, ds_signal_polls.question / options / description / discussion_url, and ds_records.content / content_json. Passing any of them to dangerouslySetInnerHTML as raw HTML is a stored-XSS vector.

For plain text, rely on React's JSX auto-escaping{dao.name} is already safe. That covers the common case (names, titles, details, hex blobs) with no extra work.

<h1>{dao.name}</h1>
<p>{proposal.details}</p>
<code>{proposal.proposal_data}</code>

When a field is meant to render as Markdown (description, an announcement body, a member bio), convert it with marked and then sanitize the resulting HTML with DOMPurify against a strict allow-list. The allow-list must restrict URI schemes so javascript: cannot survive.

import { marked } from "marked";
import DOMPurify from "dompurify";
 
const SANITIZE = {
  ALLOWED_TAGS: [
    "p", "br", "strong", "em", "u", "s", "del",
    "h1", "h2", "h3", "h4", "h5", "h6",
    "ul", "ol", "li", "blockquote", "pre", "code",
    "a", "img", "table", "thead", "tbody", "tr", "th", "td",
    "hr", "sup", "sub",
  ],
  ALLOWED_ATTR: ["href", "src", "alt", "title", "class"],
  ALLOWED_URI_REGEXP: /^(?:(?:https?|ipfs):\/\/)/i, // only http, https, ipfs
  ALLOW_DATA_ATTR: false,
};
 
function renderMarkdown(untrusted: string): string {
  const rawHtml = marked.parse(untrusted, { async: false });
  return DOMPurify.sanitize(rawHtml, SANITIZE);
}
 
function Description({ text }: { text: string }) {
  return <div dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }} />;
}

This is the only acceptable use of dangerouslySetInnerHTML — on output that DOMPurify has just sanitized. Never feed it a raw column.

Validate URLs

Allow only the http:, https:, and ipfs: schemes. Block javascript: and data: outright, even though the indexer also enforces the scheme allow-list — a bad URL must never reach an href, src, or any other navigable sink.

const SAFE_SCHEME = /^(https?:\/\/|ipfs:\/\/)/i;
const IPFS_GATEWAY = "https://gateway.pinata.cloud/ipfs/";
 
function safeUrl(url: string | null | undefined): string | null {
  if (!url || typeof url !== "string") return null;
  const trimmed = url.trim();
  if (!SAFE_SCHEME.test(trimmed)) return null;
  // ipfs://CID is not browser-loadable — rewrite to an HTTPS gateway
  if (/^ipfs:\/\//i.test(trimmed)) return IPFS_GATEWAY + trimmed.slice(7);
  return trimmed;
}

Render a link only through safeUrl, and always carry rel="noopener noreferrer nofollow" on external anchors so a hostile target can neither reach window.opener nor inherit your referrer or ranking:

function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
  const url = safeUrl(href);
  if (!url) return <span>{children}</span>; // fall back to inert text
  return (
    <a href={url} target="_blank" rel="noopener noreferrer nofollow">
      {children}
    </a>
  );
}

For images, validate the same way and set referrerPolicy="no-referrer", crossOrigin="anonymous", and an onError fallback so a broken or hostile avatar_img degrades to a placeholder.

Render by trust level

Every ds_records row carries a trust_level the indexer computed from who posted it. Use it to decide how prominently to present content and which badge to show. A MEMBER post must never look identical to a VERIFIED one — the badge is the only thing separating an individual's opinion from the DAO's official voice.

trust_levelMeaningSuggested badge
VERIFIEDPosted by the vault via governance — the DAO speaking officiallyGreen
VERIFIED_INITIALSet by the deployer before governance existedBlue
SEMI_TRUSTEDPosted by a DAO-approved navigator contractYellow
MEMBERA verified shareholder's personal opinionGray
ON_CHAIN_PROVISIONALOrphan / pre-DAO record, provisionally on-chain verifiedNeutral

UNTRUSTED posts are rejected at index time and never appear in the database, so you will never render one. Treat an unknown trust_level as untrusted: render no badge rather than a default one.

const BADGE: Record<string, { label: string; tone: string }> = {
  VERIFIED: { label: "Verified (Governance)", tone: "green" },
  VERIFIED_INITIAL: { label: "Set by Deployer", tone: "blue" },
  SEMI_TRUSTED: { label: "Navigator", tone: "yellow" },
  MEMBER: { label: "Member", tone: "gray" },
  ON_CHAIN_PROVISIONAL: { label: "Provisional", tone: "neutral" },
};
 
function TrustBadge({ level }: { level?: string }) {
  const b = level ? BADGE[level] : undefined;
  return b ? <span className={`badge badge-${b.tone}`}>{b.label}</span> : null;
}

A navigator's trust_status is a separate gate

trust_level (on records) is not the same thing as a navigator's trust_status. Read-only and module navigators (Signal, Budget) can be deployed by anyone claiming any DAO. Hide their feeds unless trust_status === 'sanctioned' — see Navigators overview. Permissioned navigators are vouched by NavigatorSet and are always sanctioned.

Realtime: reconcile through exact HTTP reads

Realtime delivery can be missed, duplicated or reordered. Use events to invalidate the affected view and re-fetch it through precision-preserving HTTP projections. Raw change payloads do not inherit the ::text casts used in a REST select. Applying them directly can reintroduce numeric precision loss.

The SDK provides watchIndexer and supabaseRealtimeAdapter with bounded reconciliation, reconnect handling and explicit reorg hooks. Keep rendering, trust checks and URL validation on the refreshed data. A DELETE should invalidate the row and prompt reconciliation; it is not by itself proof of a particular chain event. Block number alone cannot resolve the order of multiple updates in one block.

Agents using the shared public endpoint should poll at a bounded interval instead of opening large numbers of subscriptions. Coordinate realtime quota with the hosted application's operator.

Operational signals

The indexer's health surface exposes requires_full_reindex (with reindex_reason and reindex_flagged_at). It is set when a reorg deeper than the confirmation window is detected, which means member-balance totals may have drifted until a reindex completes. When it is true, show a non-blocking "data may be stale" banner — it is not "indexer down," and the rest of the data remains readable.

Health responses are cached server-side for about 5 seconds. Polling faster than that just returns the same cached payload, so set your poll interval to 5s or slower.