---
source: https://qlik.dev/extend/extend-quickstarts/first-extension/
last_updated: 2026-03-18T16:49:43Z
---

# Build a HelloWorld extension using nebula.js

In this tutorial you'll learn how to build a simple extension that renders
a table using `nebula.js`.

It includes the following steps:

1. Create a project
2. Configure data structure
3. Render data
4. Select data
5. Build and upload extension

> **Note:** To upload the extension to Qlik Cloud you need access to a Qlik Cloud Analytics tenant, and either tenant
> administrator rights yourself, or the means to get an API key generated for you.

## Requirements

This tutorial requires you to have the following accessible:

- [Node.js](https://nodejs.org/en/download) (version 18 or newer)
- A terminal (for example [Git Bash on Windows](https://gitforwindows.org/),
  or Terminal.app on Mac)
- A modern web browser, for example Google Chrome
- A text editor or IDE of your choice, for example Visual Studio Code
- An existing [web integration](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-adminster-web-integrations.htm),
  or possibility to get one created in your tenant

## Create a project

The quickest way to get started is to use the `nebula.js` CLI:

```bash
npx @nebula.js/cli create hello --picasso none
```

The `--picasso none` option tells the command to not create a
picasso visualization template, other options are `minimal` and `barchart`.

The command scaffolds a project into the `/hello` folder with the following
structure:

- `/src`
  - `data.js` - Data configuration
  - `ext.js` - Extension settings
  - `index.js` - Main entry point of this visualization
  - `meta.json` - Metadata for the extension
  - `object-properties.js` - Default object properties
- `/test` - Integration tests
- `package.json`

The folder contains some additional dotfiles that provides linting and
formatting of code.

### Start the development server

Start the development server with:

```bash
cd hello
npm run start
```

The command starts a local development server and opens up
`http://localhost:8080` in your browser.

[image: Connect to an engine]

The development server needs to connect to a Qlik Associative Engine running in
any Qlik deployment. Enter the WebSocket URL that corresponds to the Qlik
product you are using.

For more information on connecting to various deployments, see the
[nebula CLI documentation](https://qlik.dev/extend/set-up-nebula-environment/nebula-serve/).

Next, select an app to connect to.

[image: Connect to an app]

You are then redirected to the main developer UI where you can see your
visualization rendered:

[image: Development server]

Any updates in `/src/index.js` that affects the output automatically causes a
refresh of the visualization and you can see the changes immediately.

> **Note:** If the changes do not appear, try running `nebula build` in a new terminal and refresh the browser.

## Configure data structure

A simple `Hello` message isn't that useful, time to add some data.

Add a `qHyperCubeDef` definition in `object-properties.js`:

```js
const properties = {
  qHyperCubeDef: {
    qInitialDataFetch: [{ qWidth: 2, qHeight: 10 }],
  },
  // ...
};
```

Then add `/qHyperCubeDef` as a data target in `data.js`:

```js
export default {
  targets: [
    {
      path: "/qHyperCubeDef",
    },
  ],
};
```

With only those changes you should now have the option to add data from the
property panel on the right:

[image: Data targets]

Add a dimension by clicking on **Add dimension** and selecting a value in the
menu that appears, do the same with measure.

## Render data

In order to render the data you first need to access it through the `useLayout` hook:

```js
import { useLayout, useElement, useEffect } from '@nebula.js/stardust';

// ...
component() {
  console.log(useLayout());
}
```

You can then `useLayout` in combination with `useEffect` to render the headers and
rows of data in `qHyperCube`:

```js
component() {
  const element = useElement();
  const layout = useLayout();

  useEffect(() => {
    if (layout.qSelectionInfo.qInSelections) {
      // skip rendering when in selection mode
      return;
    }
    const hc = layout.qHyperCube;

    // headers
    const columns = [...hc.qDimensionInfo, ...hc.qMeasureInfo].map((f) => f.qFallbackTitle);
    const header = `<thead><tr>${columns.map((c) => `<th>${c}</th>`).join('')}</tr></thead>`;

    // rows
    const rows = hc.qDataPages[0].qMatrix
      .map((row) => `<tr>${row.map((cell) => `<td>${cell.qText}</td>`).join('')}</tr>`)
      .join('');

    // table
    const table = `<table>${header}<tbody>${rows}</tbody></table>`;

    // output
    element.innerHTML = table;

  }, [element, layout])
}
```

[image: Data table]

## Select data

Before selecting data, meta data on each row needs to be added so that the values
can be identified for selection:

```js
// rows
const rows = hc.qDataPages[0].qMatrix
  .map(
    (row, rowIdx) =>
      `<tr data-row="${rowIdx}">
        ${row.map((cell) => `<td>${cell.qText}</td>`).join("")}
      </tr>`
  )
  .join("");
```

And then add a `'click'` event handler on `element` which does the following:

- Verifies that the clicked element is a `td`
- Begins selections in `/qHyperCubeDef` if not already activated
- Extracts the `data-row` index from `tr`
- Updates `selectedRows` based on the click `data-row`

```js
const element = useElement();
const selections = useSelections();
const [selectedRows, setSelectedRows] = useState([]);

useEffect(() => {
  const listener = (e) => {
    if (e.target.tagName === "TD") {
      if (!selections.isActive()) {
        selections.begin("/qHyperCubeDef");
      }
      const row = +e.target.parentElement.getAttribute("data-row");
      setSelectedRows((prev) => {
        if (prev.includes(row)) {
          return prev.filter((v) => v !== row);
        }
        return [...prev, row];
      });
    }
  };

  element.addEventListener("click", listener);

  return () => {
    element.removeEventListener("click", listener);
  };
}, [element]);
```

Next, update the styling of the selected rows in the table whenever they change:

```js
useEffect(() => {
  if (!layout.qSelectionInfo.qInSelections) {
    // no need to update when not in selection mode
    return;
  }
  element.querySelectorAll("tbody tr").forEach((tr) => {
    const idx = +tr.getAttribute("data-row");
    tr.style.backgroundColor = selectedRows.includes(idx) ? "#eee" : "";
  });
}, [element, selectedRows, layout]);
```

Finally, apply the selected values through the `selections` API:

```js
useEffect(() => {
  if (selections.isActive()) {
    if (selectedRows.length) {
      selections.select({
        method: "selectHyperCubeCells",
        params: ["/qHyperCubeDef", selectedRows, []],
      });
    } else {
      selections.select({
        method: "resetMadeSelections",
        params: [],
      });
    }
  } else if (selectedRows.length) {
    setSelectedRows([]);
  }
}, [selections.isActive(), selectedRows]);
```

## Build and upload extension

You have so far been working in a local isolated environment on a chart that's not
dependent on any specific Qlik product.

The command:

```bash
npm run build
```

generates a bundle into the `/dist` folder which is all you need to distribute
the chart as an npm package.

Qlik Sense however requires some additional files and slightly different format
to interpret the chart as an extension.
To generate these files you can run the command:

```bash
npm run sense
```

The command generates all files into the folder `/hello-ext`, which you then
can use as an extension in your choice of Qlik Sense.

To upload the extension to your Qlik Cloud tenant, follow the instructions on
[managing extensions](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-extensions.htm).
