> ## Documentation Index
> Fetch the complete documentation index at: https://backstage.spotify.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Creating a Custom No-Code UI for a Third-Party Integration

> Build a No-Code UI frontend extension for a custom Soundcheck fact collector so users can configure integrations through the Soundcheck interface instead of YAML.

## Overview

This tutorial guides you through building a No-Code UI for a custom integration. A No-Code UI provides a more user-friendly way to configure your fact collector options directly through the Soundcheck interface, rather than manually editing YAML configuration files.

Soundcheck offers a No-Code UI configuration option for every [integration](../core-concepts/fact-collectors) developed by Spotify. To create a No-Code UI for your custom integration, you can leverage [frontend extensions](https://backstage.io/docs/frontend-system/architecture/extensions/) available in the [new frontend](https://backstage.io/docs/frontend-system/) system.

<Info>
  You cannot directly edit, extend, or replace the No-Code UI for integrations
  pre-built and shipped by Soundcheck. If you need a custom No-Code UI for an
  existing Soundcheck integration, you must build a custom integration for that
  fact collector along with a new No-Code UI, then disable the pre-built one.
  See [Step 5: Configure Integration
  Extension](#step-5-configure-integration-extension) for how to disable a
  pre-built integration.
</Info>

**What you'll build:** A No-Code UI for the `ExamplePagerDutyFactCollector` (the custom PagerDuty integration we built in the [Creating a Custom Third-Party Integration](./custom-fact-collector) tutorial). This No-Code UI allows users to configure the integration directly from the Soundcheck Integrations page, rather than editing YAML files. We'll also disable the pre-shipped PagerDuty integration to use our custom one instead.

**Time to complete:** \~20 minutes

<Accordion title="📖 What are integration extensions?">
  Integration extensions are the pages that appear when you click "Configure" on an Integration within the Soundcheck Integrations tab. The "Configure" button will only display for an integration when an integration extension is installed for that Third-Party Integration.

  <Info>
    It's currently not possible to change the content of the integration table's
    rows.
  </Info>

  The following integration extensions are provided by Soundcheck and enabled by default:

  ```yaml app-config.yaml theme={"theme":{"light":"github-light","dark":"dracula"}}
  app:
    extensions:
      - integration:soundcheck/azure # Azure DevOps Integration
      - integration:soundcheck/bigquery # BigQuery Integration
      - integration:soundcheck/catalog # Software Catalog Integration
      - integration:soundcheck/datadog # Datadog Integration
      - integration:soundcheck/data-registry # Data Registry Integration
      - integration:soundcheck/github # GitHub Integration
      - integration:soundcheck/gitlab # GitLab Integration
      - integration:soundcheck/http # HTTP Integration
      - integration:soundcheck/jira # Jira Integration
      - integration:soundcheck/kubernetes # Kubernetes Integration
      - integration:soundcheck/newrelic # NewRelic Integration
      - integration:soundcheck/pagerduty # PagerDuty Integration
      - integration:soundcheck/scm # Source Code Management Integration
      - integration:soundcheck/sonarqube # SonarQube Integration
  ```
</Accordion>

***

## Prerequisites

Before starting, ensure you have:

* Backstage migrated to the [new frontend](https://backstage.io/docs/frontend-system/building-apps/migrating) system (Portal uses the new frontend system exclusively)

<Info>
  Either a complete transition or a hybrid migration to the new frontend system
  is supported. If using a hybrid migration, complete Steps 1–6 first, then
  follow the [Additional Steps for Hybrid Frontend
  Migrations](#additional-steps-for-hybrid-frontend-migrations) section.
</Info>

* A Third-Party Integration that is configurable (implements `ConfigurableFactCollector` interface). Most integrations developed by Spotify are configurable. Exceptions: [Tech Insights](../core-concepts/fact-collectors/3p-integrations/techinsights) and [Soundcheck](../core-concepts/fact-collectors/3p-integrations/soundcheck) (they will be configurable in the future)

* Soundcheck plugin modules and the corresponding Third-Party Integration backend module installed in your Backstage instance. See [Creating a Custom Third-Party Integration](./custom-fact-collector) for how to create one

***

## Implementation

### Step 1: Create the Frontend Plugin

Use the Backstage CLI to create a new frontend plugin for your integration:

```bash theme={"theme":{"light":"github-light","dark":"dracula"}}
yarn new
```

When prompted, enter:

| Prompt                      | Value                     |
| --------------------------- | ------------------------- |
| What do you want to create? | **frontend-plugin**       |
| Enter the ID of the plugin  | `soundcheck-integrations` |

You'll see: **"Successfully created frontend-plugin."**

<Info>
  The new frontend system is still in alpha and therefore not yet the default
  when creating frontend plugins. Frontend plugins should follow the [migration
  guide](https://backstage.io/docs/frontend-system/building-plugins/migrating)
  to create an alpha export (this will be covered in the next steps).
</Info>

You may remove the `ExampleComponent` & `ExampleFetchComponent` and their exports/imports that were created by the CLI.

***

### Step 2: Create the alpha.tsx File

Create an `alpha.tsx` file that will contain your plugin declaration compatible with the new frontend system:

```tsx plugins/soundcheck-integrations/src/alpha.tsx theme={"theme":{"light":"github-light","dark":"dracula"}}
import { convertLegacyRouteRefs } from '@backstage/core-compat-api';
import {
  createFrontendPlugin,
  ExtensionDefinition,
} from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';

import { IntegrationPageBlueprint } from '@spotify/backstage-plugin-soundcheck';

// extension reference: integration:soundcheck-integrations/example-pagerduty
export const pagerDutyIntegration: ExtensionDefinition =
  IntegrationPageBlueprint.make({
    name: 'example-pagerduty', // extension name
    params: {
      integrationId: 'example-pagerduty', // IMPORTANT: The value must match the ID of the corresponding fact collector
      loader: async () =>
        import('./components/IntegrationComponent').then(
          (m) => <m.IntegrationComponent collectorId="example-pagerduty" />, // React component created in the previous step
        ),
    },
  });

export default createFrontendPlugin({
  pluginId: 'soundcheck-integrations',
  routes: convertLegacyRouteRefs({
    root: rootRouteRef,
  }),
  extensions: [pagerDutyIntegration],
});
```

<Info>
  Ensure `integrationId` and `collectorId` match the ID of the corresponding
  fact collector (the custom fact collector you have created).
</Info>

<Accordion title="📖 Integration Extension Configuration Options">
  The `IntegrationPageBlueprint` supports the following configuration options:

  | Option                 | Required | Description                                                                                                                                     |
  | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
  | `name`                 | No       | Extension name, should be unique for all integration extensions. Must be provided if your plugin implements more than one integration extension |
  | `disabled`             | No       | A boolean flag, if set to `true` it disables the extension by default                                                                           |
  | `params.integrationId` | Yes      | ID of the corresponding fact collector                                                                                                          |
  | `params.loader`        | Yes      | A function that returns the custom implementation of the No-Code UI (React component)                                                           |
  | `params.path`          | No       | A path to the integration page. Default value is `/soundcheck/integrations/:integrationId`                                                      |
  | `params.routeRef`      | No       | A routing reference to expose a path in Backstage's [routing system](https://backstage.io/docs/frontend-system/architecture/routes)             |

  The extension reference follows the pattern `<kind>:<namespace>/<name>` where:

  | Part        | Description                                                                                           |
  | ----------- | ----------------------------------------------------------------------------------------------------- |
  | `kind`      | Defaulted to `integration` for extensions created from `IntegrationPageBlueprint` extension blueprint |
  | `namespace` | ID of your frontend plugin that implements the extension (example: `soundcheck-integrations`)         |
  | `name`      | Extension name (example: `pagerduty`)                                                                 |
</Accordion>

***

### Step 3: Update package.json

Update your `package.json` file in the `soundcheck-integrations` plugin:

* Include `alpha.tsx` file in exports and typesVersions
* Include `@spotify/backstage-plugin-soundcheck`, `@backstage/core-compat-api` and `@backstage/frontend-plugin-api` modules in dependencies

```json plugins/soundcheck-integrations/package.json highlight={8-18,20-23} theme={"theme":{"light":"github-light","dark":"dracula"}}
{
  "name": "@internal/backstage-plugin-soundcheck-integrations",
  "description": "Custom Soundcheck Integrations.",
  "version": "0.1.0",
  "backstage": {
    "role": "frontend-plugin"
  },
  "exports": {
    ".": "./src/index.ts",
    "./alpha": "./src/alpha.tsx",
    "./package.json": "./package.json"
  },
  "typesVersions": {
    "*": {
      "alpha": ["src/alpha.tsx"],
      "package.json": ["package.json"]
    }
  },
  "dependencies": {
    "@backstage/core-compat-api": "backstage:^",
    "@backstage/frontend-plugin-api": "backstage:^",
    // minimum version that supports custom No-Code UI is 0.17.0
    "@spotify/backstage-plugin-soundcheck": "workspace:^"
  }
}
```

<Info>
  The `backstage:^` version syntax requires the [Backstage Yarn
  plugin](https://backstage.io/docs/golden-path/create-app/keeping-backstage-updated/#managing-package-versions-with-the-backstage-yarn-plugin)
  to be installed in your project. This plugin automatically resolves the
  correct package version based on your Backstage release.
</Info>

Then run:

```bash theme={"theme":{"light":"github-light","dark":"dracula"}}
yarn install
```

<Accordion title="📖 Soundcheck frontend exports for No-Code UI">
  The Soundcheck frontend module exports the following constants and functions for creating a custom No-Code UI:

  | Export                     | Description                                                                                                                                                         |
  | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `IntegrationPageBlueprint` | [Extension blueprint](https://backstage.io/docs/frontend-system/architecture/extension-blueprints/) for the custom integration config page                          |
  | `useGetCollectors`         | React hook that fetches collector configurations from the database and/or YAML by their IDs. See [Appendix: React Hooks Reference](#appendix-react-hooks-reference) |
  | `useUpdateCollectorConfig` | React hook that saves collector configuration to the database. See [Appendix: React Hooks Reference](#appendix-react-hooks-reference)                               |
</Accordion>

***

### Step 4: Create the Integration React Component

Implement a React component that will display the configuration options for the Third-Party Integration:

```tsx plugins/soundcheck-integrations/src/components/IntegrationComponent.tsx theme={"theme":{"light":"github-light","dark":"dracula"}}
import { Content } from '@backstage/core-components';
import { Button, Grid, TextField, Typography } from '@material-ui/core';
import {
  useGetCollectors,
  useUpdateCollectorConfig,
} from '@spotify/backstage-plugin-soundcheck';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

export const IntegrationComponent = ({
  collectorId,
}: {
  collectorId: string;
}) => {
  const navigate = useNavigate();
  const navigateBack = () => navigate('/soundcheck/integrations');

  // fetching integration data from the database and/or YAML
  const { data } = useGetCollectors([collectorId]);
  const { mutateAsync: updateCollectorConfig } = useUpdateCollectorConfig();

  const collector = data?.[0];
  const [config, setConfig] = useState<string>();

  useEffect(() => {
    if (collector?.config) {
      setConfig(JSON.stringify(collector.config, null, '\t'));
    }
  }, [collector]);

  if (!collector)
    return (
      <Typography variant="h1">{collectorId} Integration Not Found</Typography>
    );

  const disabled = !collector.isEditable || !collector.isConfigurable;

  const onSave = async () => {
    // saving integration data to the database
    const result = await updateCollectorConfig({
      collectorId: collectorId,
      config: config ? JSON.parse(config) : {},
    });
    if (result) navigateBack();
  };

  return (
    <Content>
      <Grid container direction="column" spacing={2}>
        <Grid item>
          <Typography variant="h1">
            {collector.name ?? collector.id} Integration
          </Typography>
          <Typography variant="subtitle2">{collector.description}</Typography>
        </Grid>
        <Grid item>
          <TextField
            label="JSON Configuration"
            placeholder="Enter JSON configuration"
            variant="outlined"
            disabled={disabled}
            fullWidth
            multiline
            minRows={5}
            maxRows={25}
            value={config}
            onChange={(event) => setConfig(event.target.value)}
          />
        </Grid>
        <Grid item>
          <Grid container spacing={1}>
            <Grid item>
              <Button
                variant="contained"
                color="primary"
                disabled={disabled}
                onClick={onSave}
              >
                Save
              </Button>
            </Grid>
            <Grid item>
              <Button
                variant="text"
                color="primary"
                disabled={disabled}
                onClick={navigateBack}
              >
                Cancel
              </Button>
            </Grid>
          </Grid>
        </Grid>
      </Grid>
    </Content>
  );
};
```

<Info>
  This is a demo component that doesn't implement any validation and it's not
  meant to be used as-is in production.
</Info>

***

### Step 5: Configure Integration Extension

While extensions derived from the `IntegrationPageBlueprint` blueprint do not offer custom configuration options, they do universally support the `disabled` option (note that integration extensions are enabled by default).

Add the following configuration to disable the pre-shipped PagerDuty integration and enable the No-Code UI for the custom PagerDuty integration you just built:

```yaml app-config.yaml theme={"theme":{"light":"github-light","dark":"dracula"}}
app:
  extensions:
    # Disable the pre-shipped PagerDuty integration
    - integration:soundcheck/pagerduty:
        disabled: true
    # Enable the custom PagerDuty integration No-Code UI
    - integration:soundcheck-integrations/example-pagerduty
```

***

### Step 6: Verify Your Integration

1. Save your changes and run your Backstage instance
2. Navigate to **Soundcheck** → **Integrations**
3. Confirm your custom integration shows a "Configure" button

Clicking the "Configure" button will take you to the No-Code UI that you created in [Step 4](#step-4-create-the-integration-react-component).

🎉 **Congratulations!** Your custom No-Code UI is now running.

***

## Additional Steps for Hybrid Frontend Migrations

If you have completed a full transition to the new frontend system and have [frontend feature discovery](https://backstage.io/docs/frontend-system/architecture/app/#feature-discovery) enabled, no additional steps are required—your plugins will be discovered and registered automatically.

However, if you have done a **hybrid migration** to the new frontend system (feature discovery is not enabled), you need to manually register the features by following these additional steps.

### Hybrid Step 1: Add Dependencies to Your App

Add the following dependencies to your `packages/app/package.json` and run `yarn install`:

```json packages/app/package.json theme={"theme":{"light":"github-light","dark":"dracula"}}
{
  "dependencies": {
    "@internal/backstage-plugin-soundcheck-integrations": "workspace:^",
    "@spotify/backstage-plugin-soundcheck": "^0.19.8"
  }
}
```

```bash theme={"theme":{"light":"github-light","dark":"dracula"}}
yarn install
```

<Info>
  Make sure the dependency name for your custom integration is correct. If you
  have followed this tutorial with no changes, the name above is correct.
</Info>

### Hybrid Step 2: Remove the Soundcheck Route

Remove the Soundcheck Route from `packages/app/src/App.tsx` as this will now be handled by the new frontend system.

```tsx packages/app/src/App.tsx highlight={36-39} theme={"theme":{"light":"github-light","dark":"dracula"}}
const routes = (
  <FlatRoutes>
    <Route path="/" element={<Navigate to="catalog" />} />
    <Route path="/catalog" element={<CatalogIndexPage />} />
    <Route
      path="/catalog/:namespace/:kind/:name"
      element={<CatalogEntityPage />}
    >
      {entityPage}
    </Route>
    <Route path="/docs" element={<TechDocsIndexPage />} />
    <Route
      path="/docs/:namespace/:kind/:name/*"
      element={<TechDocsReaderPage />}
    >
      <TechDocsAddons>
        <ReportIssue />
      </TechDocsAddons>
    </Route>
    <Route path="/create" element={<ScaffolderPage />} />
    <Route path="/api-docs" element={<ApiExplorerPage />} />
    <Route
      path="/catalog-import"
      element={
        <RequirePermission permission={catalogEntityCreatePermission}>
          <CatalogImportPage />
        </RequirePermission>
      }
    />
    <Route path="/search" element={<SearchPage />}>
      {searchPage}
    </Route>
    <Route path="/settings" element={<UserSettingsPage />} />
    <Route path="/catalog-graph" element={<CatalogGraphPage />} />
    <Route path="/notifications" element={<NotificationsPage />} />
    <Route
      path="/soundcheck"
      element={<SoundcheckRoutingPage title="Soundcheck" />}
    />
  </FlatRoutes>
);
```

### Hybrid Step 3: Register the Plugins

Import the Soundcheck plugin and your custom integration, then add them to the `features` array in `createApp` in `App.tsx`:

```tsx packages/app/src/App.tsx theme={"theme":{"light":"github-light","dark":"dracula"}}
import soundCheckPlugin from '@spotify/backstage-plugin-soundcheck/alpha';
import examplePagerDutyIntegration from '@internal/backstage-plugin-soundcheck-integrations/alpha';

const app = createApp({
  features: [
    // ... other features
    soundCheckPlugin,
    examplePagerDutyIntegration,
  ],
  // ... other options
});
```

<Info>
  Make sure the import path for your custom integration is correct. If you have
  followed this tutorial with no changes, the path above is correct.
</Info>

<Accordion title="📖 Full example: App.tsx with hybrid migration">
  ```tsx packages/app/src/App.tsx theme={"theme":{"light":"github-light","dark":"dracula"}}
  import React from 'react';
  import { createApp } from '@backstage/frontend-defaults';
  import { convertLegacyApp } from '@backstage/core-compat-api';

  // Import the Soundcheck plugin and your custom integration
  import soundCheckPlugin from '@spotify/backstage-plugin-soundcheck/alpha';
  import examplePagerDutyIntegration from '@internal/backstage-plugin-soundcheck-integrations/alpha';

  // Your legacy app routes (excluding the Soundcheck route which is now handled by the new frontend system)
  const legacyApp = convertLegacyApp();
  // ... your legacy routes

  const app = createApp({
    features: [
      legacyApp,
      soundCheckPlugin,
      examplePagerDutyIntegration,
      // ... other features
    ],
  });

  export default app.createRoot();
  ```
</Accordion>

### Hybrid Step 4: Verify Your Integration

1. Save your changes and run your Backstage instance
2. Navigate to **Soundcheck** → **Integrations**
3. Confirm your custom integration shows a "Configure" button

🎉 **Congratulations!** Soundcheck will now register your No-Code UI plugin. You should be able to click the "Configure" button for your custom integration and access the configuration page you built.

***

## Appendix: React Hooks Reference

The Soundcheck frontend module provides the following React hooks for building custom No-Code UI components.

### useGetCollectors

<Accordion title="📖 useGetCollectors - Fetch collector configurations">
  React hook that fetches collector configurations from the database (`collector` table) and/or YAML by their IDs.
  If the collector's configuration is found in both database and YAML and the YAML configuration is a
  plain object (not an array), the configurations will be merged (it's recommended to configure secrets
  in YAML only and don't expose secret configuration in No-Code UI for security reasons).
  Otherwise, the database configuration takes precedence. Collector's YAML configuration
  can be found under `soundcheck.collectors.<integrationId>` in `app-config.yaml` file.

  **Usage example:**

  ```ts theme={"theme":{"light":"github-light","dark":"dracula"}}
  import { useGetCollectors } from '@spotify/backstage-plugin-soundcheck';

  const { data } = useGetCollectors(['pagerduty']);
  const collector = data?.[0];
  ```

  **Collector type definition:**

  ```ts theme={"theme":{"light":"github-light","dark":"dracula"}}
  import { JsonObject } from '@backstage/types';

  export type Collector = {
    // Integration ID provided in the backend implementation of the fact collector.
    // Implemented in `FactCollector.id`
    id: string;
    // Integration name provided in the backend implementation of the fact collector.
    // Implemented in `FactCollector.name`
    name?: string;
    // Integration description provided in the backend implementation of the fact collector.
    // Implemented in `FactCollector.description`
    description?: string;
    // A flag that's set to true if the collector's backend implements `ConfigurableFactCollector` interface.
    isConfigurable: boolean;
    // A flag that's set to true if the collector is configurable and the configuration in the YAML file is a plain object (not an array).
    isEditable: boolean;
    // The names of the facts that this collector can collect.
    // Implemented in `FactCollector.getFactNames()`
    factNames: string[];
    // Current collector configuration as JSON.
    // Implemented in `ConfigurableFactCollector.getConfig()`
    config?: JsonObject;
    // JSON schema for the collectors configuration (can be used for validation).
    // Implemented in `ConfigurableFactCollector.getConfigSchema()`
    configSchema?: JsonObject;
    // Current collector configuration as `CollectionConfig[]` array.
    // Implemented in `FactCollector.getCollectionConfigs()`
    collectionConfigs: {
      // The facts to which this collection configuration applies.
      factRefs: string[];
      // A filter specifying which entities to collect the specified facts for.
      filter?: JsonObject;
      // The frequency at which the specified facts should be collected.
      frequency?: JsonObject;
      // If the collected facts should be cached, and if so for how long.
      cache?: JsonObject;
      // A filter specifying which entities to exclude from collection.
      exclude?: JsonObject;
    }[];
  };
  ```

  **Example response:**

  ```json theme={"theme":{"light":"github-light","dark":"dracula"}}
  {
    "id": "pagerduty",
    "name": "PagerDuty",
    "description": "Collects facts from your PagerDuty instance.",
    "isConfigurable": true,
    "isEditable": true,
    "factNames": [
      "pagerduty:default/service",
      "pagerduty:default/standards",
      "pagerduty:default/incidents"
    ],
    "config": {
      "collects": [
        {
          "type": "Service",
          "frequency": {
            "hours": 1
          },
          "filter": [
            {
              "kind": ["Component"]
            }
          ],
          "cache": false
        },
        {
          "type": "Standards",
          "frequency": {
            "hours": 1
          },
          "filter": [
            {
              "kind": ["Component"]
            }
          ],
          "cache": false
        },
        {
          "factName": "incidents",
          "type": "Incidents",
          "statuses": ["triggered", "acknowledged", "resolved"],
          "frequency": {
            "hours": 1
          },
          "filter": [
            {
              "kind": ["Component"]
            }
          ],
          "cache": false
        }
      ]
    },
    "collectionConfigs": [
      {
        "factRefs": ["pagerduty:default/service"],
        "filter": [
          {
            "kind": ["Component"]
          }
        ],
        "frequency": {
          "hours": 1
        },
        "cache": false,
        "exclude": null
      },
      {
        "factRefs": ["pagerduty:default/standards"],
        "filter": [
          {
            "kind": ["Component"]
          }
        ],
        "frequency": {
          "hours": 1
        },
        "cache": false,
        "exclude": null
      },
      {
        "factRefs": ["pagerduty:default/incidents"],
        "filter": [
          {
            "kind": ["Component"]
          }
        ],
        "frequency": {
          "hours": 1
        },
        "cache": false,
        "exclude": null
      }
    ]
  }
  ```
</Accordion>

### useUpdateCollectorConfig

<Accordion title="📖 useUpdateCollectorConfig - Save collector configuration">
  React hook that saves collector configuration to the database (`collector` table). No-Code UI configuration
  is always stored in the database as a raw JSON object. The configuration JSON object should only include the
  actual collector's configuration (aka what you'd configure under `soundcheck.collectors.<integrationId>`
  in `app-config.yaml` file as YAML config). The configuration should be a valid JSON object, otherwise
  an error will be thrown by the backend.

  On top of persisting the config to the database the hook also calls `ConfigurableFactCollector.setConfig()`
  method implemented by the fact collector, so the fact collector will be aware of the updated config right away.

  **Usage example:**

  ```ts theme={"theme":{"light":"github-light","dark":"dracula"}}
  import { useUpdateCollectorConfig } from '@spotify/backstage-plugin-soundcheck';

  const { mutateAsync: updateCollectorConfig } = useUpdateCollectorConfig();

  const onSave = async () => {
    const result = await updateCollectorConfig({
      collectorId: 'pagerduty', // must be a valid integrationId
      // must be a valid JSON configuration object
      config: {
        collects: [
          {
            type: 'Service',
            cache: false,
            filer: [
              {
                kind: ['Component'],
              },
            ],
            frequency: {
              hours: 1,
            },
          },
        ],
      },
    });
    if (result) console.log('Success!');
  };
  ```
</Accordion>
