Embed a report in Next.js

A Next.js route handler signs an embed token for the signed-in user, and a page shows the report with the halo-report element, each reader seeing their own rows.

When you're done, a page in your Next.js app shows a published Halo report. Your server decides who's looking and signs a short-lived token, and each reader sees only their own region's rows. Your readers don't need a Halo account.

This uses the App Router with TypeScript and React 19.

Before you start

You need to be an Owner or Admin (org.manage) to make a secret.

  1. Make a signing secret. In Halo Data, under Settings, Embedding, choose Make a secret. Halo shows it once. Note its Secret id (kid).

  2. Get the report's id. It's under Embed in the report's Share dialog, and the report must be published.

  3. List your site. In the same place, add localhost:3000 under Sites it may be embedded on, and your own domain when you deploy. See Allowed sites.

HALO_EMBED_SECRET=hes_…
HALO_EMBED_SECRET_ID=SECRET_ID
HALO_REPORT_ID=REPORT_ID
NEXT_PUBLIC_HALO_ORIGIN=https://acme.usehalo.net

NEXT_PUBLIC_HALO_ORIGIN is your organization's Halo address, and is fine in the browser. The others stay on the server, so don't prefix them with NEXT_PUBLIC_. Keep this file out of git.

npm install jsonwebtoken
npm install --save-dev @types/jsonwebtoken

1. Sign a token

A route handler runs only on your server. It signs with HS256, names the secret in kid, and lasts an hour.

import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; // your own session code

export const dynamic = "force-dynamic";

export async function GET() {
  const user = await getCurrentUser();
  if (!user) {
    return NextResponse.json({ error: "Sign in first." }, { status: 401 });
  }

  const token = jwt.sign(
    {
      report: process.env.HALO_REPORT_ID,
      // Added to every query. A reader can't remove them.
      filters: [{ field: "region", op: "in", values: user.regions }],
      // What row policies read as {{user.regions}}.
      attributes: { regions: user.regions },
    },
    process.env.HALO_EMBED_SECRET as string,
    { algorithm: "HS256", keyid: process.env.HALO_EMBED_SECRET_ID, expiresIn: "1h" },
  );

  return NextResponse.json({ token }, { headers: { "Cache-Control": "no-store" } });
}

jsonwebtoken adds iat itself, and works out exp from expiresIn. Your token can last at most 24 hours. Use your dataset's field name in filters, by its name rather than its label. Each value in attributes has to be a string or a list of strings, or the token is refused. Anyone can decode a JWT, so put nothing in it the reader shouldn't read.

2. Show the report

This client component fetches a token, loads the element's script, and draws the report. When a token expires, the element reports invalid_token, so it fetches another. Setting token reloads the frame, so it does this only when it has to.

"use client";

import Script from "next/script";
import { type DetailedHTMLProps, type HTMLAttributes, useCallback, useEffect, useRef, useState } from "react";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "halo-report": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
        token?: string;
        origin?: string;
        theme?: "light" | "dark" | "system";
        element?: string;
        filters?: string;
      };
    }
  }
}

export function HaloReport({ title }: { title: string }) {
  const ref = useRef<HTMLElement>(null);
  const [token, setToken] = useState<string>();
  const lastRefresh = useRef(0);

  const refresh = useCallback(async () => {
    lastRefresh.current = Date.now();
    const response = await fetch("/api/halo-embed-token");
    if (response.ok) setToken((await response.json()).token);
  }, []);

  useEffect(() => {
    refresh();
  }, [refresh]);

  useEffect(() => {
    const report = ref.current;
    if (!report) return;
    const onError = (event: Event) => {
      const { code, message } = (event as CustomEvent<{ code: string; message: string }>).detail;
      // A token that's refused straight away won't be fixed by another one.
      if (code === "invalid_token" && Date.now() - lastRefresh.current > 60_000) refresh();
      else console.warn(code, message);
    };
    report.addEventListener("halo:error", onError);
    return () => report.removeEventListener("halo:error", onError);
  }, [refresh, token]);

  if (!token) return null;

  return (
    <>
      <Script type="module" src="https://cdn.usehalo.net/halo-report.js" />
      <halo-report
        ref={ref}
        token={token}
        origin={process.env.NEXT_PUBLIC_HALO_ORIGIN}
        theme="system"
        title={title}
      />
    </>
  );
}
import { HaloReport } from "@/components/halo-report";

export default function RevenuePage() {
  return <HaloReport title="Revenue by region" />;
}

Run npm run dev, sign in to your app, and open http://localhost:3000/reports/revenue.

The element's attributes

Every attribute is in The <halo-report> element.

AttributeValue here
tokenThe signed token. Used when there's no src
originYour organization's Halo address. Always set it with token: left out, it falls back to where the script came from, and cdn.usehalo.net doesn't serve reports
themelight, dark or system. Left out, it's light
titleThe iframe's accessible name. Set it before the element connects
elementOne element's id, to draw it alone. A token's element claim wins over it
filtersJSON: reader filters to start with, by field

The frame takes its content's height unless you size the element: give halo-report a height or min-height in your CSS to set one.

3. Check who sees what

Sign in as two users with different regions, and confirm each sees only their own.

  • Locked filters in the token are added to every query, and a reader can't remove them. A locked filter applies only to datasets that have its field. A token whose locked filter fits no dataset it opens is refused with invalid_token, which names the field.

  • Row policies read the token's attributes, and apply to every query on their dataset. A reader with no values for an attribute a policy reads sees no rows. Where one dataset could show someone the wrong rows, prefer a row policy.

If it doesn't show

What you seeMeansWhat to do
Nothing where the report should beThere's no token yetCheck the route handler answers, and you're signed in
"This report can't be shown here", with embed_domain_not_allowedYour site isn't listed for this reportAdd it under Sites it may be embedded on
invalid_token. The reason is in detail on the failed /v1/public/report request, in the network tabThe token didn't verify, or expiredSee When a token is refused
report_not_publishedIt has never been publishedPublish the report
locked_filter_goneA locked filter on the report no longer fits its datasetFix the filter, and publish the report again
No rowsA row policy has no values to matchCheck the token's attributes

The element sends every problem to halo:error: see its errors. If your site sets a Content-Security-Policy, allow https://cdn.usehalo.net in script-src, and your organization's Halo address in frame-src.

In production

  • Mint a token per page load, with a short life. Someone who copies a token can use it until it expires.

  • Keep HALO_EMBED_SECRET in your host's secret settings. To replace it, choose Rotate under Settings, Embedding: tokens signed with the current secret keep working for 24 hours, so your servers can move over.

  • List only the sites you use, and add your real domain to the report's sites before you deploy.