Skip to main content

Overview

Widgets are the building blocks of the Radarboard dashboard. They display data from integrations in a 3x3 grid, with optional expanded views. Each widget lives under widgets/ and uses a template recipe system — you declare layouts as data structures, not JSX.

Prerequisites

  • Radarboard dev environment set up (Setup Guide)
  • At least one integration configured (widgets consume integration data)
  • Familiarity with React and TypeScript

Step 1: Scaffold

pnpm create-widget my-widget
This creates widgets/my-widget/ with all boilerplate, registers it in radarboard.config.ts, and runs pnpm install.

Step 2: Define the Recipe

Edit index.ts to set your layout. The recipe system lets you compose widgets from primitives:
import { buildTemplateRecipe, type TemplateRecipeModel } from "@radarboard/widget-engine/templates";

const recipe: TemplateRecipeModel = {
  kind: "summary_list",
  summary: [
    {
      type: "kpi-row",
      metrics: [
        { label: "Total", source: { sourceId: "my-widget", field: "totalCount" } },
        { label: "Active", source: { sourceId: "my-widget", field: "activeCount" } },
      ],
    },
  ],
  content: [
    {
      type: "list",
      source: { sourceId: "my-widget", field: "items" },
      itemTemplate: {
        title: { sourceId: "my-widget", field: "name" },
        subtitle: { sourceId: "my-widget", field: "status" },
      },
      emptyMessage: "No items found",
    },
  ],
};

Recipe Kinds

KindLayoutBest for
content_onlySingle content areaLists, tables, charts
summary_listKPI strip + scrollable listMost widgets
summary_contentKPI strip + rich contentCharts with stats
summary_chart_listKPI strip + chart + listAnalytics dashboards
rail_contentSide rail + main contentDetail views
rail_listSide rail + scrollable listCategory browsing
summary_onlyKPI metrics onlyStatus displays
feed_listActivity feedTimelines, logs

Section Types

TypeDescription
listScrollable item list with title/subtitle/badge
chartLine, bar, or area chart
tableData table with columns
kpi-rowHorizontal metric cards
headline-statLarge featured number
summary-quad2x2 metric grid
activity-chartGitHub-style contribution heatmap
stream-listReal-time event stream
card-listCard grid layout
alertWarning/info banner
tabsTabbed content switching

Step 3: Wire Up Data

Define types (types.ts)

export interface MyWidgetData {
  items: Array<{ id: string; name: string; status: string }>;
  totalCount: number;
  activeCount: number;
}

Create the hook (hooks/use-my-widget.ts)

"use client";

import useSWR from "swr";

const POLL_INTERVAL = 5 * 60_000; // 5 minutes

export function useMyWidget(projectSlug: string | null) {
  const url = projectSlug
    ? `/api/integrations/my-service/data?project=${projectSlug}`
    : "/api/integrations/my-service/data";

  const { data, error, isLoading, mutate } = useSWR(url, async (u) => {
    const res = await fetch(u);
    if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
    return res.json();
  }, { refreshInterval: POLL_INTERVAL });

  return { data: data ?? null, error: error ?? null, isLoading, refetch: () => mutate() };
}

Connect to the template (data-resolver.tsx)

"use client";

import { registerTemplateDataSource, reportResolverState } from "@radarboard/widget-sdk";
import { useMyWidget } from "./hooks/use-my-widget";

function MyWidgetResolver({ config, onData }) {
  const { data, error, isLoading } = useMyWidget(config?.projectSlug ?? null);

  reportResolverState(onData, "my-widget", {
    loading: isLoading,
    error: error?.message ?? null,
    configured: true,
    fetchedAt: null,
    stale: false,
    data: data ?? null,
  });

  return null;
}

registerTemplateDataSource("my-widget", MyWidgetResolver);

Step 4: Configure the Descriptor

export const myWidgetDescriptor: WidgetDescriptor<WidgetTemplateConfig> = {
  id: "my-widget",
  name: "My Widget",
  description: "Shows data from My Service.",      // max 120 chars
  requiredIntegrations: ["my-service"],             // which integrations must be configured
  defaultSlot: "slot5",                             // grid position (slot1-slot9)
  component: MyWidgetCompact,
  expandedComponent: MyWidgetExpanded,
  defaultConfig: MY_WIDGET_TEMPLATE_CONFIG,
};

Step 5: Test

Run conformance tests:
pnpm --filter @radarboard/widget-my-widget test

Adding Variants

Offer different views of the same widget that users can switch between:
variants: [
  {
    id: "overview",
    name: "Overview",
    isDefault: true,
    config: overviewTemplateConfig,
  },
  {
    id: "detailed",
    name: "Detailed",
    config: detailedTemplateConfig,
  },
],

Expanded Views

The compact component renders in the grid card. The expanded component renders in a larger overlay when the user clicks expand. Both receive the same props:
export function MyWidgetExpanded({
  widgetId, projectSlug, timeRange, config, onFetchedAt, onRefetch,
}: WidgetRenderProps<WidgetTemplateConfig>) {
  return (
    <TemplateWidgetExpanded
      widgetId={widgetId}
      projectSlug={projectSlug}
      timeRange={timeRange}
      config={config}
      onFetchedAt={onFetchedAt}
      onRefetch={onRefetch}
    />
  );
}

Data Flow

Integration API → SWR Hook → Data Resolver → Template Engine → Widget UI

                            reportResolverState()

                         Template sections render
                         using recipe + resolved data

Module Boundaries

Widgets can only import from:
  • @radarboard/widget-sdk
  • @radarboard/widget-engine
  • @radarboard/types
  • @radarboard/utils
  • @radarboard/ui
  • @radarboard/charts
  • @radarboard/hooks
  • @radarboard/assistant-ui

Reference

  • Widget system deep dive: Widget System
  • Composition patterns: Widget Composition Reference
  • SDK types: @radarboard/widget-sdkWidgetDescriptor, WidgetRenderProps
  • Recipes: @radarboard/widget-sdk/recipes — layout factory functions
  • Real examples: widgets/github-activity/, widgets/revenue/, widgets/analytics/
  • Extension rules: CONTRIBUTING-EXTENSIONS.md