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.
Make a signing secret. In Halo Data, under Settings, Embedding, choose Make a secret. Halo shows it once. Note its Secret id (kid).
Get the report's id. It's under Embed in the report's Share dialog, and the report must be published.
List your site. In the same place, add
localhost:3000under 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.netNEXT_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/jsonwebtoken1. 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.
| Attribute | Value here |
|---|---|
token | The signed token. Used when there's no src |
origin | Your 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 |
theme | light, dark or system. Left out, it's light |
title | The iframe's accessible name. Set it before the element connects |
element | One element's id, to draw it alone. A token's element claim wins over it |
filters | JSON: 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 see | Means | What to do |
|---|---|---|
| Nothing where the report should be | There's no token yet | Check the route handler answers, and you're signed in |
"This report can't be shown here", with embed_domain_not_allowed | Your site isn't listed for this report | Add 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 tab | The token didn't verify, or expired | See When a token is refused |
report_not_published | It has never been published | Publish the report |
locked_filter_gone | A locked filter on the report no longer fits its dataset | Fix the filter, and publish the report again |
| No rows | A row policy has no values to match | Check 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_SECRETin 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.