Article

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.

QueryPanel Team
11 min read
embedded analyticsReactPostgrestutorialSaaStenant isolationJWTdeveloper guide

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:

PathBackend responsibilityFrontend responsibilityBest fit
HeadlessUse QueryPanelSdkAPI.syncSchema(...) and QueryPanelSdkAPI.ask(...) from your API routeRender results with your own UI or basic react-sdk componentsZero-trust architecture, custom query flows, custom assistants, product-specific chart layouts
HeadfulSet up datasources and default dashboards in QueryPanel admin, then use QueryPanelSdkAPI.createJwt(...) to mint an embed token after resolving the tenantRender QuerypanelEmbedded with the JWTDistributing 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-sdk in the frontend
  • @querypanel/node-sdk in 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 modelExampleAnalytics implication
Shared tables with tenant_idorders.tenant_idEvery query needs a tenant filter
Customer schema per tenanttenant_a.ordersQuery path must choose the allowed schema
Database per tenantseparate connection per customerBackend 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
  • QueryInput and QueryResult from @querypanel/react-sdk
  • a custom assistant UI that calls your /api/analytics/ask route

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:

TestWhat should happen
Tenant A opens dashboardOnly Tenant A rows appear
Tenant B opens same dashboardOnly Tenant B rows appear
Browser changes tenant-like valueNo access change
User asks broad questionGenerated SQL still scopes to tenant
Export or saved view runsSame tenant rules apply
Expired embed JWT is used in the headful pathRequest 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.