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.