DocsRecipes

Recipes

Native Data API frontend reference

4 min readReviewed July 2026

Use the Data API when an iframe cannot meet your interaction, design-system, accessibility, or localization requirements. Your application still keeps the GraphJSON API key on its server and exposes only purpose-specific reports to the browser.

This reference assembles the security, response, and presentation guidance into one implementation.

Architecture#

authenticated browser
  → application report endpoint
    → authorize account and report
      → server-owned GraphJSON request
        → validate and normalize response
          → minimal application response
            → accessible native UI

The browser chooses a known report name and presentation options. It never submits arbitrary SQL, collection names, GraphJSON filters, or an account ID that bypasses authorization.

Define the browser contract first#

The UI needs a small stable shape:

type TrendPoint = {
  timestamp: string;
  value: number;
};

type TrendReport = {
  report: "daily_meaningful_actions";
  points: TrendPoint[];
  summary: {
    value: number;
    priorValue: number | null;
    changePercent: number | null;
  };
  context: {
    generatedAt: string;
    reportingTimeZone: string;
    rangeLabel: string;
    unit: "actions";
  };
};

Do not return the complete GraphJSON request, API key, telemetry, or upstream error body. The application contract can remain stable when a GraphJSON aggregation key or presentation library changes.

Register reports on the server#

import "server-only";

const reports = {
  daily_meaningful_actions: {
    collection: "product_events",
    graph_type: "Single Line",
    start: "28 days ago",
    end: "now",
    compare: "28 days ago",
    filters: [
      ["event", "=", "meaningful_activity"]
    ],
    aggregation: "Count",
    metric: null,
    split: null,
    granularity: "Day",
    customizations: {}
  }
} as const;

type ReportName = keyof typeof reports;

Version this registry when a report definition changes materially:

const REPORT_REGISTRY_VERSION = "2026-07-26.1";

The registry is both an authorization allowlist and an analytical dependency. Review it like application code.

Authorize tenant scope#

An account-aware endpoint should derive the active account from a verified session:

async function resolveReportAccess(request: Request) {
  const session = await requireSession(request);
  const account = await requireActiveAccountMembership(
    session.user.id,
    session.activeAccountId
  );

  return {
    accountId: account.id,
    timeZone: account.reportingTimeZone || "UTC",
    locale: session.locale || "en-US"
  };
}

If the route accepts an account selector, authorize it before using it. A browser-provided account ID is an input to a permission check, not permission by itself.

Call GraphJSON from the server#

type DataEnvelope = {
  result: Record<string, unknown>[];
  compare_result?: Record<string, unknown>[];
  metadata?: Record<string, unknown>;
};

async function loadGraphJSONReport(
  reportName: ReportName,
  accountId: string,
  timeZone: string
): Promise<DataEnvelope> {
  const definition = reports[reportName];

  const response = await fetch(
    "https://api.graphjson.com/api/visualize/data",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        ...definition,
        api_key: process.env.GRAPHJSON_API_KEY,
        IANA_time_zone: timeZone,
        filters: [
          ...definition.filters,
          ["account_id", "=", accountId]
        ]
      }),
      signal: AbortSignal.timeout(8_000),
      cache: "no-store"
    }
  );

  const text = await response.text();
  if (!response.ok) {
    throw new ReportUpstreamError(response.status);
  }

  const value: unknown = text ? JSON.parse(text) : null;
  if (
    !value ||
    typeof value !== "object" ||
    !Array.isArray((value as DataEnvelope).result)
  ) {
    throw new ReportContractError();
  }

  return value as DataEnvelope;
}

Never include text in a browser response or ordinary log. Record a safe error class, status, report name, tenant ID, and correlation ID.

Normalize query-derived keys#

Collection time-series rows use the aggregation-derived value key. Normalize it before the browser sees it:

function normalizeCountTrend(
  envelope: DataEnvelope
): TrendPoint[] {
  return envelope.result.map((row, index) => {
    const timestamp = row.timestamp;
    const value = row.Count;

    if (
      (typeof timestamp !== "string" &&
        typeof timestamp !== "number") ||
      typeof value !== "number" ||
      !Number.isFinite(value)
    ) {
      throw new ReportContractError(
        `Invalid row at index ${index}`
      );
    }

    return {
      timestamp: String(timestamp),
      value
    };
  });
}

Use Data API response contracts for Samples, multi-series, categorical, and SQL result shapes.

Return stable application errors#

{
  "error": {
    "code": "report_temporarily_unavailable",
    "request_id": "rptreq_01J..."
  }
}

Recommended application status behavior:

Condition Application response
No session 401
No account access 403
Unknown report 404
Invalid date or option 400
GraphJSON timeout or failure 503 with stable error code
Contract mismatch 502 with stable error code

Do not translate an upstream failure into a valid zero.

Cache at the correct boundary#

Cache the normalized report privately on the server. Include every dimension that can affect the result:

report-registry-version:
report-name:
account-id:
reporting-time-zone:
authorized-range:
query-affecting-options

Locale belongs in the cache key only if the cached response contains formatted strings. Prefer caching raw normalized numbers and formatting in the presentation layer.

Set a short lifetime that matches the product’s freshness expectation. Do not put personalized report responses in a shared public CDN cache.

Fetch from React deliberately#

Framework server loaders are preferable when they can authorize, cache, and stream the result. For a client-side refresh control, use a small hook with cancellation:

import * as React from "react";

type ReportState =
  | { status: "loading" }
  | { status: "ready"; data: TrendReport; stale: boolean }
  | { status: "empty"; data: TrendReport }
  | { status: "error"; requestId?: string };

function useTrendReport(reportName: string) {
  const [state, setState] = React.useState<ReportState>({
    status: "loading"
  });

  const load = React.useCallback(async (signal?: AbortSignal) => {
    setState({ status: "loading" });

    try {
      const response = await fetch(
        `/api/reports/${encodeURIComponent(reportName)}`,
        {
          credentials: "same-origin",
          signal
        }
      );
      const body = await response.json();

      if (!response.ok) {
        setState({
          status: "error",
          requestId: body?.error?.request_id
        });
      } else if (body.points.length === 0) {
        setState({ status: "empty", data: body });
      } else {
        setState({ status: "ready", data: body, stale: false });
      }
    } catch (error) {
      if ((error as Error).name !== "AbortError") {
        setState({ status: "error" });
      }
    }
  }, [reportName]);

  React.useEffect(() => {
    const controller = new AbortController();
    load(controller.signal);
    return () => controller.abort();
  }, [load]);

  return { state, reload: () => load() };
}

For a production application, prefer the framework’s data-loading mechanism or a maintained query cache instead of duplicating caching and request coordination in every component. React’s own useEffect reference calls out that framework data fetching is generally more efficient.

Render every state#

function DailyMeaningfulActions() {
  const { state, reload } = useTrendReport(
    "daily_meaningful_actions"
  );

  if (state.status === "loading") {
    return (
      <section aria-busy="true" aria-labelledby="activity-title">
        <h2 id="activity-title">Meaningful product activity</h2>
        <p>Loading product activity…</p>
      </section>
    );
  }

  if (state.status === "error") {
    return (
      <section aria-labelledby="activity-title">
        <h2 id="activity-title">Meaningful product activity</h2>
        <p role="status">
          This report is temporarily unavailable.
        </p>
        <button type="button" onClick={() => reload()}>
          Try again
        </button>
      </section>
    );
  }

  if (state.status === "empty") {
    return (
      <section aria-labelledby="activity-title">
        <h2 id="activity-title">Meaningful product activity</h2>
        <p>No qualifying product activity in this period.</p>
      </section>
    );
  }

  return <TrendCard report={state.data} stale={state.stale} />;
}

Keep previous data visible during a background refresh when that is less disruptive, but label it stale if the refresh fails.

Build an accessible trend card#

The visual chart and the table must describe the same values:

function TrendCard({
  report,
  stale
}: {
  report: TrendReport;
  stale: boolean;
}) {
  const formatter = new Intl.NumberFormat("en-US");
  const maximum = Math.max(
    1,
    ...report.points.map((point) => point.value)
  );

  return (
    <section aria-labelledby="activity-title">
      <h2 id="activity-title">Meaningful product activity</h2>
      <p>
        {formatter.format(report.summary.value)} actions ·{" "}
        {report.context.rangeLabel} ·{" "}
        {report.context.reportingTimeZone}
      </p>
      {stale && <p>Showing the last available result.</p>}

      <ol aria-label="Daily meaningful-action trend">
        {report.points.map((point) => (
          <li key={point.timestamp}>
            <span>{point.timestamp}</span>
            <span
              aria-hidden="true"
              style={{
                display: "inline-block",
                width: `${(point.value / maximum) * 100}%`
              }}
            />
            <strong>{formatter.format(point.value)}</strong>
          </li>
        ))}
      </ol>

      <details>
        <summary>View data table</summary>
        <table>
          <caption>
            Daily meaningful product actions,{" "}
            {report.context.reportingTimeZone}
          </caption>
          <thead>
            <tr>
              <th scope="col">Date</th>
              <th scope="col">Actions</th>
            </tr>
          </thead>
          <tbody>
            {report.points.map((point) => (
              <tr key={point.timestamp}>
                <th scope="row">{point.timestamp}</th>
                <td>{formatter.format(point.value)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </details>
    </section>
  );
}

Replace the simple bars with your reviewed chart component if needed. Keep the summary, units, reporting time zone, keyboard behavior, and table alternative.

Format at the edge#

Return numbers and timestamps from the application endpoint. Format with Intl.NumberFormat and Intl.DateTimeFormat using an authorized account locale and reporting time zone.

Do not preformat "$1,234.50" and then ask a chart library to parse it. Keep money numeric with its currency, and keep dates unambiguous.

Constrain browser options#

If users can select a range or segment, map it to an allowlist:

const allowedRanges = {
  "7d": "7 days ago",
  "30d": "30 days ago",
  "90d": "90 days ago"
} as const;

Do not accept arbitrary GraphJSON start, end, filters, or SQL. Limit range width, segment cardinality, concurrent requests, and refresh rate.

Test the complete boundary#

Automate:

  • unauthenticated, unauthorized, and recently revoked access
  • account A can never receive account B’s cached result
  • unknown report and unsupported option rejection
  • GraphJSON timeout and non-JSON response handling
  • response-contract mismatch handling
  • empty is distinct from zero and error
  • number, currency, date, and time-zone formatting
  • keyboard and screen-reader semantics
  • narrow layouts and 200% text zoom
  • request cancellation and repeat refreshes

Seed two tenants with intentionally different totals such as 3 and 103. That makes a missing filter or cache key visible.

Production checklist#

  • GraphJSON credentials exist only on the application server.
  • The browser selects a report from a server-owned allowlist.
  • Tenant scope comes from verified authorization.
  • GraphJSON responses are validated and normalized.
  • Browser responses omit upstream configuration and telemetry.
  • Private cache keys include tenant and every query dimension.
  • Loading, ready, empty, stale, and error states are distinct.
  • Visual values have a text summary and data-table alternative.
  • Locale, time zone, currency, and units are explicit.
  • Cross-tenant and failure-mode tests run before release.

Continue with Accessible and localized embedded analytics and Analytics asset change management.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support