This guide builds a small but complete plugin for Superset 4.x. The chart is deliberately simple, a bar per category with a configurable highlight threshold, so that the plugin mechanics stay in focus. Swap the rendering for ECharts, D3, or deck.gl once the plumbing is in place.
What a plugin is made of
A Superset chart plugin is an npm package that exports a class extending ChartPlugin from @superset-ui/core. The class wires together five things:
| Piece | File | Job |
|---|---|---|
| Metadata | index.ts | Name, description, thumbnail, category, tags, and behaviours shown in the chart picker |
| Query builder | buildQuery.ts | Turns the form data from the control panel into one or more query objects for Superset’s backend |
| Control panel | controlPanel.ts | Declares which controls appear in Explore and how they are grouped |
| Props transformer | transformProps.ts | Converts raw query results and form data into the props your component needs |
| Component | HelloChart.tsx | The React component that draws the chart |
The flow at runtime is: the user changes a control, Superset runs buildQuery, sends the query to the backend, receives rows, calls transformProps with those rows plus the form data, and renders your component with the result. Controls flagged as renderTrigger skip the query and go straight to transformProps, which is how colour and label changes stay instant.
Prerequisites
- Node.js at the version your Superset release pins. Read the
enginesfield insuperset-frontend/package.jsonat the tag you deploy rather than trusting a blog post: the 4.x line started on Node 18 and later releases accept Node 20 as well. Use nvm or fnm; a mismatched Node version is the most common cause of a build that fails for no obvious reason. - A checkout of the Superset repository at the exact tag you run in production, for example
git clone --branch 4.1.1 https://github.com/apache/superset.git. You will run the frontend dev server from it and, later, build the production image from it. - A running Superset backend to develop against. The repo’s docker-compose setup works, or a local
superset run -p 8088 --with-threads --reload --debugger. - Working knowledge of React and TypeScript. Plugins are TypeScript by default and there is no reason to fight that.
Step 1: Scaffold the plugin
Superset maintains a Yeoman generator that produces a working plugin skeleton. Install it and run it in a new directory next to, not inside, your Superset checkout:
npm install -g yo @superset-ui/generator-superset
mkdir superset-plugin-chart-hello
cd superset-plugin-chart-hello
yo @superset-ui/superset
Answer the prompts: choose Chart plugin, accept the package name, add a one-line description, and pick Regular as the chart type (choose Time-series if your chart has a time axis; it changes the default controls). The generator writes a package.json, TypeScript config, Jest setup, a src/ folder with the five files above, and a src/images/thumbnail.png placeholder.
If the published generator lags behind the Superset version you are targeting, run it from your checkout instead:
cd superset/superset-frontend/packages/generator-superset
npm install
npm link
cd ../../../../superset-plugin-chart-hello
yo @superset-ui/superset
Open package.json and check the peerDependencies. The generator pins @superset-ui/core and @superset-ui/chart-controls to the versions in the checkout you ran it from. These must match the Superset you deploy, or the plugin will compile against one API and run against another.
Step 2: The five files
The generated code works as-is. The point of walking through each file is to know what to change when your chart needs something different.
index.ts: metadata and wiring
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
export default class HelloChartPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
name: t('Hello Chart'),
description: t('One bar per category, with bars above a threshold highlighted.'),
thumbnail,
category: t('Custom'),
tags: [t('Comparison'), t('Custom')],
});
super({
buildQuery,
controlPanel,
loadChart: () => import('./HelloChart'),
metadata,
transformProps,
});
}
}
loadChart is a dynamic import so the component is code-split and only downloaded when someone opens a dashboard that uses it. Wrap every user-visible string in t() so it goes through Superset’s translation layer.
Note what is not here: a behaviors array. Adding Behavior.InteractiveChart tells Superset the chart emits cross-filters, and it does put cross-filter controls in the UI, but the affordance does nothing until your component actually calls setDataMask, which Superset passes in through chartProps.hooks. Declaring the behaviour before you wire the hook produces a menu item that silently fails. Add the behaviour and the hook together, or neither. Dashboard native filters apply to your chart either way; they arrive as part of the query, not through this flag.
buildQuery.ts: from form data to a query
import { buildQueryContext, QueryFormData } from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const { cols: groupby } = formData;
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
groupby,
},
]);
}
buildQueryContext assembles a query object from the standard controls (metrics, filters, row limit, time range) and hands it to your callback. You return an array of query objects; one is normal, two or more is how charts like the big number with trendline fetch both a total and a series. The generator names the group-by control cols and maps it onto the query’s groupby. Recent Superset versions also accept columns; either works on 4.x.
controlPanel.ts: what the user can change
import { t, validateNonEmpty } from '@superset-ui/core';
import {
ControlPanelConfig,
sharedControls,
} from '@superset-ui/chart-controls';
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'cols',
config: {
...sharedControls.groupby,
label: t('Category column'),
description: t('One bar per distinct value'),
},
},
],
[
{
name: 'metrics',
config: {
...sharedControls.metrics,
validators: [validateNonEmpty],
},
},
],
['adhoc_filters'],
['row_limit'],
],
},
{
label: t('Hello Chart options'),
expanded: true,
controlSetRows: [
[
{
name: 'threshold',
config: {
type: 'TextControl',
isInt: true,
default: 0,
renderTrigger: true,
label: t('Highlight threshold'),
description: t('Bars at or above this value are highlighted'),
},
},
],
[
{
name: 'highlight_color',
config: {
type: 'ColorPickerControl',
default: { r: 26, g: 169, b: 202, a: 1 },
renderTrigger: true,
label: t('Highlight colour'),
},
},
],
],
},
],
};
export default config;
Two things to notice. First, sharedControls gives you the same metric, group-by and filter controls every built-in chart uses, so the Explore experience is consistent. Second, renderTrigger: true on the threshold and colour controls means changing them re-renders without re-querying the database. Anything that only affects drawing should be a render trigger; anything that changes what data comes back should not.
The generator’s template may also import a sections helper and open the panel with a time section such as sections.legacyRegularTime. Whether that export exists depends on the release you pin: the time controls were reworked across the 4.x line as the generic chart axes behaviour became the default. Check what your @superset-ui/chart-controls actually exports before importing it, because a missing export takes out the whole control panel rather than one control. The config above needs no time section at all; adhoc_filters covers time filtering.
Control names are snake_case here. They arrive in transformProps as camelCase (highlight_color becomes highlightColor), because ChartProps runs the form data through a camelCase conversion and keeps the original on rawFormData. This conversion is the single most common source of “my control value is undefined”.
transformProps.ts: shaping data for the component
import { ChartProps, DataRecord } from '@superset-ui/core';
export interface HelloChartProps {
width: number;
height: number;
data: DataRecord[];
categoryColumn: string;
metricLabel: string;
threshold: number;
highlightColor: string;
}
export default function transformProps(chartProps: ChartProps): HelloChartProps {
const { width, height, formData, queriesData } = chartProps;
const { cols, metrics, threshold, highlightColor } = formData;
const data = (queriesData[0]?.data ?? []) as DataRecord[];
const metric = metrics?.[0];
const metricLabel =
typeof metric === 'string' ? metric : metric?.label ?? 'value';
const rgba = highlightColor
? `rgba(${highlightColor.r}, ${highlightColor.g}, ${highlightColor.b}, ${highlightColor.a})`
: '#1aa9ca';
return {
width,
height,
data,
categoryColumn: Array.isArray(cols) ? cols[0] : cols,
metricLabel,
threshold: Number(threshold) || 0,
highlightColor: rgba,
};
}
queriesData is an array with one entry per query object you returned from buildQuery. Each entry has a data array of row objects keyed by column or metric label. Metrics can be plain strings (saved metrics) or ad-hoc metric objects with a label; handle both. Keep this function pure and cheap; it runs on every render trigger.
HelloChart.tsx: the component
import React from 'react';
import { styled } from '@superset-ui/core';
import { HelloChartProps } from './transformProps';
const Wrapper = styled.div<{ height: number; width: number }>`
height: ${({ height }) => height}px;
width: ${({ width }) => width}px;
box-sizing: border-box;
display: flex;
align-items: stretch;
gap: 6px;
padding: 8px;
font-family: ${({ theme }) => theme.typography.families.sansSerif};
`;
/* One column per row: bar area on top, label pinned underneath. */
const Column = styled.div`
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
`;
/* Gives the bar a definite height to take a percentage of. */
const BarArea = styled.div`
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: flex-end;
`;
const Bar = styled.div<{ pct: number; color: string }>`
width: 100%;
height: ${({ pct }) => pct}%;
background-color: ${({ color }) => color};
border-radius: 2px 2px 0 0;
`;
const Label = styled.div`
flex: 0 0 auto;
padding-top: 4px;
font-size: 11px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
export default function HelloChart({
width,
height,
data,
categoryColumn,
metricLabel,
threshold,
highlightColor,
}: HelloChartProps) {
const max = Math.max(1, ...data.map(row => Number(row[metricLabel]) || 0));
return (
<Wrapper width={width} height={height}>
{data.map(row => {
const value = Number(row[metricLabel]) || 0;
const pct = (value / max) * 100;
const hot = value >= threshold;
const category = String(row[categoryColumn]);
return (
<Column key={category} title={`${category}: ${value}`}>
<BarArea>
<Bar pct={pct} color={hot ? highlightColor : '#ccc'} />
</BarArea>
<Label>{category}</Label>
</Column>
);
})}
</Wrapper>
);
}
The nesting is worth a moment, because it is the part people get wrong first. A percentage height only resolves against a parent with a definite height. Wrapper gets one from the pixel height Superset hands it, and BarArea gets one from being a flex child of Wrapper, so height: ${pct}% on the bar works. Put that percentage directly inside an auto-height div and every bar collapses to nothing.
Otherwise this is plain HTML and CSS on purpose. It has no chart library dependency, it is trivially testable, and it demonstrates the two things every component must do: fill the width and height Superset gives it, and read its theme from @superset-ui/core rather than hard-coding fonts. For anything more demanding, look at how @superset-ui/plugin-chart-echarts wraps ECharts, or how the deck.gl plugins handle WebGL. The pattern is the same; only the drawing changes.
Step 3: Run it inside Superset
The plugin has to be part of the Superset frontend bundle. Superset does carry an experimental DYNAMIC_PLUGINS feature flag that fetches a plugin from a URL at runtime, but it has stayed experimental for years and nothing in the dashboard experience is built around it, so treat compiling into the bundle as the supported path.
The package’s entry point is its build output, not its source, so build it before linking:
cd superset-plugin-chart-hello
npm i --force
npm run build
The --force is there because the generator’s peer dependency ranges rarely resolve cleanly against a single Superset checkout. Then link the package into the Superset frontend:
cd ../superset/superset-frontend
npm i -S ../../superset-plugin-chart-hello
Then open src/visualizations/presets/MainPreset.js and add the plugin alongside the built-ins:
import HelloChartPlugin from 'superset-plugin-chart-hello';
// ... inside the plugins array passed to super()
new HelloChartPlugin().configure({ key: 'hello_chart' }),
The key is the chart’s viz_type. It is stored on every saved chart that uses the plugin, so choose it once and never change it.
Now run three processes: the backend, the Superset dev server, and a watch build for the plugin.
# terminal 1, from the repo root with your virtualenv active
superset run -p 8088 --with-threads --reload --debugger
# terminal 2
cd superset/superset-frontend
npm run dev-server
# terminal 3, in the plugin directory
npm run dev
That third terminal is not optional. Superset resolves the linked package through its main field, which points at compiled output, so editing src/HelloChart.tsx changes nothing until the plugin is rebuilt. npm run dev is the generator’s watch build; check the scripts block in your package.json if the name differs. Without it you will edit code for twenty minutes and conclude that hot reloading is broken.
Open http://localhost:9000, create a chart, and search for “Hello Chart” in the picker. Pick a dataset, a category column and a metric, and you should see bars. Change the threshold and watch the bars recolour without a spinner, which confirms the render trigger is wired correctly.
If the dev server does not pick up a rebuilt plugin, restart it; watching across a symlink is occasionally flaky on Windows and in Docker volumes.
Step 4: Getting it into production
Because the plugin is compiled into the bundle, production means building Superset’s frontend with your plugin included. The official apache/superset image does not contain the frontend source, so you build from the repository at the tag you deploy:
- In your Superset checkout, add the plugin dependency to superset-frontend/package.json and the registration line in MainPreset.js. Use a git URL or a private registry entry, not the relative path you developed against: the plugin directory sits outside the Docker build context, so a relative dependency fails at
npm cirather than merely being untidy. Commit both changes on a branch named for the Superset version, for example 4.1.1-acme. - Build the image from the repo root:
docker build -t registry.example.com/superset:4.1.1-acme .. The Dockerfile’s frontend build stage runsnpm ciand the frontend build, which now includes your plugin. - Deploy that image in place of the official one. Nothing else changes: same configuration, same metadata database, same Helm chart or compose file.
When you upgrade Superset, rebase the branch onto the new tag, bump the plugin’s @superset-ui/* peer dependencies to match, rebuild, and run the plugin’s tests. Budget half a day for a minor version and more for a major one; the ChartPlugin API is stable, but control-panel helpers and theme tokens do move.
Step 5: Tests worth writing
The generator sets up Jest. Three tests pay for themselves immediately:
- transformProps with a realistic ChartProps fixture: assert the metric label resolution for both string and ad-hoc metrics, the camelCase control names, and the empty queriesData case.
- buildQuery with sample form data: assert the query object contains the expected groupby and metrics, so a control rename does not silently produce an empty query.
- The component with React Testing Library: render with three rows, assert three bars, and assert the highlighted count for a given threshold.
Superset’s frontend also has a Storybook (superset-frontend/storybook). Adding a story for your plugin gives designers and stakeholders a place to look at it without a running backend.
Pitfalls that cost time
- Peer dependency drift. @superset-ui/core in your plugin must be the version the Superset frontend uses. Two copies of the library in one bundle produce baffling errors about themes or registries being undefined.
- Forgetting the plugin’s watch build. The linked package serves compiled output, so source edits are invisible until it rebuilds.
- camelCase vs snake_case. Controls are defined in snake_case and read in camelCase. Log formData once in transformProps if a value is missing.
- Forgetting renderTrigger. Every cosmetic control without it forces a database round trip.
- Declaring behaviours you have not implemented.
Behavior.InteractiveChartwithout asetDataMaskcall gives users a cross-filter option that does nothing. - Percentage sizing inside auto-height containers. Give the parent a definite height or the chart renders empty at full data.
- Hard-coded colours and fonts. Read them from the theme so the chart looks right in a themed or white-labelled Superset.
- Changing the viz_type key. Saved charts reference it. Renaming it orphans every chart built on the plugin.
- Ignoring width and height. Superset tells your component its size. A chart that sizes itself overflows dashboard grid cells.
- Forking Superset core. Put chart logic in the plugin package, not in the Superset repo. The only lines you should be maintaining in the fork are the dependency and the registration.
What we have built this way
The mechanics above are the same ones behind the plugins we have written up on this blog: a dual-axis line chart for Superset 4 that plots two measures on independent axes and takes part in cross-filtering, and a multi-threshold heatmap for Superset 4.1 that colours cells against several hard limits instead of one gradient. Both started as exactly the skeleton in this post.
If you have a chart in mind and want to know what it would take, our Plugin Scoping Call is a free 45-minute session with a plugin engineer. You describe the visualisation, we walk through the data shape and interactions, and within 48 hours you get a written scope, effort estimate, and timeline you can act on with or without us. The wider service is described on our Superset plugin development page.