Build Tenant-Safe Embedded Analytics in React and Postgres: A SaaS Tutorial
A practical tutorial for building tenant-safe embedded analytics in a React and Postgres SaaS app with JWT auth, server-side tenant context, QueryPanel, and safe SQL execution.
If your SaaS app already runs on React and Postgres, the first embedded analytics project should not require a warehouse migration, a separate BI portal, or a fragile tenant filter in the browser.
Last updated June 2026: tutorial flow for React + Postgres, server-side JWTs, tenant isolation, and QueryPanel implementation links.
Short answer: to build tenant-safe embedded analytics in a React and Postgres SaaS app, keep identity on your server, attach Postgres with tenant settings, sync schema, then choose one of two UI paths. In a headless flow, your backend calls QueryPanelSdkAPI.ask(...) and your UI renders the result with your own components or the basic React SDK components. In a headful flow, your backend mints an embed JWT with createJwt(...) and the browser renders QuerypanelEmbedded. Never trust a tenant ID from frontend state as the security boundary.
This tutorial is written for product engineers adding customer-facing analytics to a multi-tenant SaaS product. It focuses on the architecture and implementation sequence, not a toy chart.
For the buying criteria behind this pattern, see Best Embedded Analytics Tools for Postgres + React SaaS Apps and Iframe vs Native React Embed for Embedded Analytics.
What you are building
There are two common implementation paths:
| Path | Backend responsibility | Frontend responsibility | Best fit |
|---|---|---|---|
| Headless | Use QueryPanelSdkAPI.syncSchema(...) and QueryPanelSdkAPI.ask(...) from your API route | Render results with your own UI or basic react-sdk components | Zero-trust architecture, custom query flows, custom assistants, product-specific chart layouts |
| Headful | Set up datasources and default dashboards in QueryPanel admin, then use QueryPanelSdkAPI.createJwt(...) to mint an embed token after resolving the tenant | Render QuerypanelEmbedded with the JWT | Distributing managed dashboards and workspace experiences to customers |
The important detail is where trust begins. The browser can render analytics, but it should not decide which tenant the user belongs to. Both paths start with the same server-side tenant resolution and Postgres attachment.
Before you start
You need:
- a React app or Next.js app
- a Postgres database with a tenant column, such as
tenant_id - a backend route that can read your authenticated user/session
- a QueryPanel workspace
@querypanel/react-sdkin the frontend@querypanel/node-sdkin the backend
If you are still deciding whether to build or buy, read How to Add Embedded Analytics to Your SaaS Without Rebuilding Your Backend.
Step 1: Identify the tenant boundary
Start with the database model. Most SaaS apps have one of these tenant patterns:
| Tenant model | Example | Analytics implication |
|---|---|---|
Shared tables with tenant_id | orders.tenant_id | Every query needs a tenant filter |
| Customer schema per tenant | tenant_a.orders | Query path must choose the allowed schema |
| Database per tenant | separate connection per customer | Backend must select the right datasource |
The shared-table model is common for Postgres SaaS apps, so this tutorial assumes a tenant_id column.
Example tables:
orders(id, tenant_id, customer_id, amount, status, created_at)
customers(id, tenant_id, name, country, plan)
events(id, tenant_id, user_id, event_name, created_at)
Your analytics layer should know that tenant_id is the isolation field. That is not just a filter convenience. It is part of the security contract.
Step 2: Keep tenant resolution on the server
Your app probably already has a session, cookie, or JWT. Use that to resolve the tenant in a backend route.
Do not do this:
// Unsafe pattern: frontend chooses the tenant.
<Analytics tenantId={localStorage.getItem("tenantId")} />
Do this instead:
// Server-side route or loader.
async function getAnalyticsContext(request: Request) {
const user = await requireUser(request);
const tenant = await getTenantForUser(user.id);
return {
userId: user.id,
organizationId: tenant.organizationId,
tenantId: tenant.id,
};
}
This is the same principle as any customer-facing API: authentication first, authorization second, rendering last.
Step 3: Attach Postgres with tenant settings
On your backend, use the Node SDK to describe the database and tenant field.
import { QueryPanelSdkAPI } from "@querypanel/node-sdk";
import { Pool } from "pg";
const qp = new QueryPanelSdkAPI(
process.env.QUERYPANEL_API_URL!,
process.env.QUERYPANEL_PRIVATE_KEY!,
process.env.QUERYPANEL_WORKSPACE_ID!,
);
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
});
const createPostgresClient = () => async (sql: string, params?: unknown[]) => {
const result = await pool.query(sql, params);
return {
rows: result.rows,
fields: result.fields.map((field) => ({ name: field.name })),
};
};
qp.attachPostgres("analytics", createPostgresClient(), {
database: "app_db",
description: "Customer usage, orders, and account analytics",
tenantFieldName: "tenant_id",
tenantFieldType: "String",
enforceTenantIsolation: true,
allowedTables: ["orders", "customers", "events"],
});
Keep POSTGRES_URL, private keys, and workspace secrets on the server. They should never be bundled into React.
For the deeper security pattern, see Zero-Trust SDK Architecture.
Step 4: Sync schema metadata
Before natural-language questions or generated charts work well, the analytics layer needs schema context.
await qp.syncSchema("analytics", {
tenantId: "tenant_123",
});
Schema sync should include table and column metadata, but not raw customer data. Add business context as you learn where users get confused:
- glossary terms such as "active account" or "net revenue"
- database annotations for important tables and columns
- gold SQL examples for common questions
- tenant isolation settings
This is how you move from a demo that writes plausible SQL to a production system that understands your product language.
Step 5: Choose headless or headful
After schema sync, decide which product experience you want.
Use headless when you want your own UI and a zero-trust architecture. Your backend calls QueryPanelSdkAPI.ask(...); the SDK authenticates with the private key and workspace ID you passed to new QueryPanelSdkAPI(...), so you do not mint a separate embed JWT for this request path. Your database credentials stay on your backend, and query execution stays in your infrastructure instead of moving raw credentials or customer data into the browser.
Use headful when you want QueryPanel's deployed dashboard/workspace UI. You can set up datasources and default dashboards in the QueryPanel admin interface, then distribute those dashboards to customers with the QuerypanelEmbedded React SDK. Your backend mints a short-lived JWT with createJwt(...), passes only that JWT to the browser, and React renders the customer-facing dashboard.
Path A: Headless analytics with QueryPanelSdkAPI.ask
In the headless path, the browser sends a question to your own API route. Your server resolves the authenticated tenant and calls qp.ask(...).
This path is the strongest fit when you want to keep the data plane under your control. QueryPanel helps generate and structure the analytics response, but your backend keeps the database connection, credentials, tenant resolution, and SQL execution path.
// /api/analytics/ask
export async function POST(request: Request) {
const context = await getAnalyticsContext(request);
const body = await request.json();
const result = await qp.ask(body.question, {
database: "analytics",
tenantId: context.tenantId,
pipeline: "v2",
});
return Response.json({
success: true,
message: result.message,
sql: result.sql,
params: result.params,
rows: result.rows,
fields: result.fields?.map((field) =>
typeof field === "string" ? field : field.name
),
chart: result.chart,
rationale: result.rationale,
});
}
From React, you can render the response however you want:
- your own chart and table components
QueryInputandQueryResultfrom@querypanel/react-sdk- a custom assistant UI that calls your
/api/analytics/askroute
For example, a basic React SDK UI can call your headless endpoint:
import {
QueryPanelProvider,
QueryInput,
QueryResult,
LoadingState,
ErrorState,
EmptyState,
useQueryPanel,
} from "@querypanel/react-sdk";
export function AnalyticsAssistant() {
return (
<QueryPanelProvider
config={{
askEndpoint: "/api/analytics/ask",
colorPreset: "default",
}}
>
<AnalyticsBody />
</QueryPanelProvider>
);
}
function AnalyticsBody() {
const { query, result, isLoading, error, ask, colorPreset } = useQueryPanel();
return (
<div>
<QueryInput value={query} onSubmit={ask} isLoading={isLoading} />
{isLoading && !result && <LoadingState />}
{error && <ErrorState message={error} />}
{!isLoading && !error && !result && <EmptyState />}
{result && (
<QueryResult
result={result}
query={query}
isLoading={isLoading}
colorPreset={colorPreset}
/>
)}
</div>
);
}
For customer-facing UI, keep rationale language client-safe. Admin/debug tooling can ask for deeper SQL explanations, but end users do not need database paths or dialect mechanics.
Path B: Headful analytics with QuerypanelEmbedded
In the headful path, the customer opens a full embedded dashboard or workspace. The frontend requests an embed token from your backend, and the backend mints that token only after resolving the authenticated tenant.
This path is useful when your team wants to prepare dashboards centrally in QueryPanel admin, publish a default customer analytics experience, and embed that managed experience directly into the SaaS product.
// /api/analytics/embed-token
export async function GET(request: Request) {
const context = await getAnalyticsContext(request);
const jwt = await qp.createJwt({
organizationId: context.organizationId,
tenantId: context.tenantId,
expiresIn: "15m",
});
return Response.json({ jwt });
}
The exact route shape depends on your framework. The invariant is more important than the syntax: the server signs the JWT after resolving the authenticated tenant.
Now the browser can render the embedded analytics UI with the short-lived JWT.
import { useEffect, useState } from "react";
import { QuerypanelEmbedded } from "@querypanel/react-sdk";
export function CustomerAnalyticsPage() {
const [jwt, setJwt] = useState<string | null>(null);
useEffect(() => {
fetch("/api/analytics/embed-token")
.then((response) => response.json())
.then((data) => setJwt(data.jwt));
}, []);
if (!jwt) {
return <div>Loading analytics...</div>;
}
return (
<QuerypanelEmbedded
dashboardId="your-dashboard-id"
apiBaseUrl="https://api.querypanel.io"
jwt={jwt}
allowCustomization
/>
);
}
This gives customers a product-native analytics route without making them leave your application.
Step 6: Test tenant isolation before launch
Do not launch because the chart looks good. Launch when the tenant boundary is proven.
Minimum tests:
| Test | What should happen |
|---|---|
| Tenant A opens dashboard | Only Tenant A rows appear |
| Tenant B opens same dashboard | Only Tenant B rows appear |
| Browser changes tenant-like value | No access change |
| User asks broad question | Generated SQL still scopes to tenant |
| Export or saved view runs | Same tenant rules apply |
| Expired embed JWT is used in the headful path | Request fails |
Use seeded test tenants with obviously different data. It should be easy to spot a leak during QA.
Step 7: Start with one dashboard and five questions
Do not launch with every metric in your database. Start with one focused customer workflow.
For example:
- "Usage overview"
- "Revenue by month"
- "Failed payments by reason"
- "Top countries by activity"
- "Inactive users by team"
Then define five natural-language questions customers are likely to ask. Add gold SQL examples for those questions once the correct query shape is known.
For choosing the first customer workflow, see Embedded Analytics Use Cases for SaaS Products and Embedded Analytics Readiness Checklist for SaaS Teams.
Step 8: Decide what belongs in paid analytics
Once the first dashboard is stable, decide which parts belong in the base product and which belong in a premium analytics tier.
Good premium candidates:
- saved custom dashboards
- scheduled reports
- AI question limits
- advanced exports
- role-based access
- audit logs
- benchmarking
- customer-specific metric definitions
For packaging, see How to Price and Package Your First Embedded Analytics Tier.
Common mistakes
Mistake 1: Trusting the frontend tenant ID
The frontend can display tenant context, but it should not prove tenant identity. Resolve tenant access on the server.
Mistake 2: Treating dashboard filters as security
A filter is a UI control. Tenant isolation is an authorization rule. Do not confuse them.
Mistake 3: Sending raw credentials into analytics vendors
If your security model requires credential sharing, run a serious review. QueryPanel's preferred pattern keeps credentials and SQL execution in your infrastructure.
Mistake 4: Launching AI before metric definitions are ready
Natural language is only useful when the system knows what your business terms mean. Add glossary terms and gold SQL examples early.
Mistake 5: Hiding SQL from engineers during rollout
End users do not need SQL details, but engineers need enough visibility to debug generation, execution, and tenant scoping.
FAQ
Can I build embedded analytics in React without an iframe?
Yes. A React SDK can render analytics inside your application shell, using your routing, layout, and design system. This usually feels more native than a cross-origin iframe.
How do I keep embedded analytics tenant-safe?
Resolve tenant identity on the server, configure tenant isolation in the analytics layer, and verify that generated SQL or saved dashboards cannot return cross-tenant rows. In the headful path, mint short-lived embed JWTs only after that server-side tenant check.
Should Postgres SaaS teams move data to a warehouse before embedding analytics?
Not always. Many SaaS teams can launch useful customer-facing analytics from Postgres first. A warehouse can help later when volume, modeling, or performance requirements outgrow operational tables.
What should I build after the first embedded dashboard?
Add the smallest feature that removes a real customer workflow: saved views, scheduled reports, AI follow-up questions, exports, or role-based access. Avoid expanding into a full BI surface until customers are using the first dashboard regularly.