Skip to content

Create Backstage Frontend Plugin

Let's start with creating Backstage Frontend Plugin.

I'm going to write how I created my plugin. This is based on the official guide and a Medium blog post (series). So, this is not something I invented and there likely isn't anything new here.

The idea here is that I write down my experience to serve as my long term memory.

The prior art are these two resources:

Steps

The steps we'll take, are the following:

  1. Initialize a new plugin with Yarn
  2. Verify the plugin works within Backstage
  3. Change the code to work as a TAB for Catalog Entities
  4. Abstract the Data provider for the Frontend via an API and Interface
  5. Change the code to call the Backend
  6. Build and publish it as a standalone NPMJS package

Init Frontend Plugin

To get started, get to your Backstage code copy. Make sure you're at the root of the code base.

Example

The root directly should be like this:

tree -L 1
.
├── README.md
├── app-config.local.yaml
├── app-config.production.yaml
├── app-config.yaml
├── backstage.json
├── catalog-info.yaml
├── dist-types
├── environment.sh
├── examples
├── lerna.json
├── node_modules
├── package.json
├── packages
├── playwright.config.ts
├── plugins
├── tsconfig.json
└── yarn.lock

To create the plugin, we run the yarn new command:

yarn new --select plugin

This generates your plugin, which is now located in the folder ./plugins/<nameOfPlugin>.

The initial response should be something like this:

? Enter the ID of the plugin [required] hello

Creating frontend plugin @internal/plugin-hello

 Checking Prerequisites:
  availability  plugins/hello   creating      temp dir 
 Executing Template:
  templating    package.json.hbs   templating    README.md.hbs   copying       .eslintrc.js   templating    index.tsx.hbs   templating    index.ts.hbs   templating    plugin.test.ts.hbs   templating    routes.ts.hbs   templating    plugin.ts.hbs   copying       setupTests.ts   templating    ExampleComponent.test.tsx.hbs   templating    ExampleComponent.tsx.hbs   copying       index.ts   templating    ExampleFetchComponent.test.tsx.hbs   templating    ExampleFetchComponent.tsx.hbs   copying       index.ts 
 Installing:
  moving        plugins/hello   app           adding dependency   app           adding import   executing     yarn install   executing     yarn lint --fix 
🎉  Successfully created plugin

Verify The Plugin Works

Unlike the backend plugin, we do not need to manually add the frontend plugin to backstage.

In the root of the Backstage project, start up a Dev instance:

yarn dev

This should startup the Backstage Frontend and Backend servers, and open the frontend in a browser.

Navigate to your newly created plugin via: http://localhost:3000/<myPluginName>.

For example, localhost:3000/hello.

There is some placeholder content with a User List.

export PLUGIN_NAME=

You can also run the plugin in isolation via the yarn workspace command:

yarn workspace @backstage/plugin-${PLUGIN_NAME} start 

Change Plugin to Software Catalog Entry Tab

We'll make the following changes, as derived from John Tucker's blog posts, Part 1 and Part II.

  1. Strip unused code
  2. Remove the plugin from Backstage frontend's Routing configuration
  3. Import the plugin in the Catalog's Entity page
  4. Adhere to naming conventions
  5. Change our Plugin from a Page to a content Tab

All the changes we'll make below, are assumed to be in the folder of the plugin we created. For the sake of keeping to the code I know works, I'll assume the plugin is named Hello.

Strip Unused Code

The first thing we're going to do, is strip the plugin to the minimal we can.

Which of course mean we'll print Hello World! and nothing else.

We do so by changing the contents of the ExampleComponent that is autogenerated.

ExampleComponent/ExampleComponent.tsx

src/components/ExampleComponent/ExampleComponent.tsx
import React from 'react';

export const ExampleComponent = () => (
    <div>Hello World!</div>
);

There's a test file for the original contents. We can remove this, src/components/ExampleComponent/ExampleComponent.test.tsx, as creating the tests for our plugin's Components is out of scope.

If you run yarn dev and navigate to our plugin, you'll see it now prints Hello World! and nothing else.

Remove From Routing

To be clear, this is in the Backstage source code itself.

Remove the import and the Route entry related to our plugin from the App.tsx:

App.tsx

packages/app/srx/App.tsx
import { HelloPage } from '@internal/plugin-hello';
packages/app/srx/App.tsx
const routes = (
    ...
    <Route path="/hello" element={<HelloPage />} /> 
}

Import Into Entity Page

We essentially do a reverse move here.

Update EntityPage.tsx by adding the import line we just removed, and add a EntityLayout.Route entry.

EntityPage.tsx

packages/app/src/components/catalog/EntityPage.tsx
import { HelloPage } from '@internal/plugin-hello';
packages/app/src/components/catalog/EntityPage.tsx
const serviceEntityPage = (
    ...
    <EntityLayout.Route path="/hello" title="Hello">
        <HelloPage />
    </EntityLayout.Route>
);

To be able to view our page now, we need at least a single Software Catalog Entity registered. If you do not have one on hand, John Tucker has one on GitHub.

If we start our Backstage again via yarn dev, navigate to a Software Catalog Entity (via the Home page), you should see your Tab!

Adhere to Naming Conventions

Now that we are building a UI for a tab within a Backstage Component (Entity), it appears that the convention is that we need to rename our React Component from MyPluginPage to EntityMyPluginContent. - John Tucker

Fair enough. Let's update our plugin's naming.

And just as I am copying from [John Tucker]'s blog posts, he was inspired by the Kubernetes plugin. It's always good to read more than one reference.

So, what do we do? We need to rename some components in the following files in our plugin:

  • src/routes.ts
  • src/plugin.ts
  • src/index.ts
  • dev/index.ts

And then update the import and usage in Backstage's "EntityPage.tsx"

routes.ts

src/routes.ts
export const rootCatalogHelloRouteRef = createRouteRef({
    id: 'hello',
});

plugin.ts

src/plugin.ts
import { rootCatalogHelloRouteRef } from './routes';

export const EntityHelloContent = helloPlugin.provide(
    createRoutableExtension({
        name: 'EntityHelloContent',
        component: () =>
            import('./components/ExampleComponent').then(m => m.ExampleComponent),
            mountPoint: rootCatalogHelloRouteRef,
    }),
);

index.ts

src/index.ts
export { helloPlugin, EntityHelloContent } from './plugin';

dev/index.ts

src/dev/index.ts
import { helloPlugin, EntityHelloContent } from '../src/plugin';
src/dev/index.ts
createDevApp()
    .registerPlugin(helloPlugin)
    .addPage({
        element: <EntityHelloContent />,
        title: 'Root Page',
        path: '/hello'
    })
    .render();

EntityPage.tsx

packages/app/src/components/catalog/EntityPage.tsx
import { EntityHelloContent } from '@internal/plugin-hello';
packages/app/src/components/catalog/EntityPage.tsx
const serviceEntityPage = (
    ...
    <EntityLayout.Route path="/hello" title="Hello">
        <EntityHelloContent />
    </EntityLayout.Route>
);

Change Into A TAB

We've changed our plugin to be a Entity content Tab, rather than a Page.

However, when we run the plugin in Dev mode it registering itself as a Page.

Let's update the src/dev/index.ts to register itself as a Entity Content Tab instead:

dev/index.ts

We need to import Entity and EntityProvider:

src/dev/index.ts
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';

As we don't have the full Backstage available in this mode, we also have no content. In order to view our plugin as it should be, we create a Mock entity:

src/dev/index.ts
const mockEntity: Entity = {
    apiVersion: 'backstage.io/v1alpha1',
    kind: 'Component',
    metadata: {
        name: 'backstage',
        description: 'backstage.io',
        annotations: {
        'backstage.io/kubernetes-id': 'dice-roller',
        },
    },
    spec: {
        lifecycle: 'production',
        type: 'service',
        owner: 'user:guest',
    },
};

And then wrap our plugin's Content in an EntityProvider element and feed it our Mock:

src/dev/index.ts
createDevApp()
    .registerPlugin(helloPlugin)
    .addPage({
        path: '/fixture-1',
        title: 'Fixture 1',
        element: (
            <EntityProvider entity={mockEntity}>
                <EntityHelloContent />
            </EntityProvider>
        ),
    })
    .render();
Full Example
src/dev/index.ts
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { createDevApp } from '@backstage/dev-utils';
import { helloPlugin, EntityHelloContent } from '../src/plugin';

const mockEntity: Entity = {
    apiVersion: 'backstage.io/v1alpha1',
    kind: 'Component',
    metadata: {
        name: 'backstage',
        description: 'backstage.io',
        annotations: {
        'backstage.io/kubernetes-id': 'dice-roller',
        },
    },
    spec: {
        lifecycle: 'production',
        type: 'service',
        owner: 'user:guest',
    },
};

createDevApp()
    .registerPlugin(helloPlugin)
    .addPage({
        path: '/fixture-1',
        title: 'Fixture 1',
        element: (
            <EntityProvider entity={mockEntity}>
                <EntityHelloContent />
            </EntityProvider>
        ),
    })
    .render();

Use Catalog Entity Data

It is great that our plugin is now a proper Tab in the Catalog Entity screen.

But, we're not using any information of this entity.

Let's change that, so that we can say "Hello " rather than "hello World", that would be quite something!

Enter the folder of the plugin, and add the plugin-catalog-react package via yarn:

yarn add @backstage/plugin-catalog-react

We then update our Component, ExampleComponent.tsx, to use the name of the Entity (and the appropriate imports):

ExampleComponent.tsx

```typescript title="src/components/ExampleComponent/ExampleComponent.tsx import React from 'react'; import { useEntity } from '@backstage/plugin-catalog-react';

export const ExampleComponent = () => { const { entity } = useEntity(); return

Hello {entity.metadata.name}
; } ```

Our plugin now says Hello to the Entity your are visiting.

Create API/Interface

As noted by John Tucker in is blog post, the Kubernetes plugin has created an Interface (which he calls an API) for the Frontend plugin to talk.

This means we can supply a Mock Client as alternative implementation during local development.

This way we do not depend on the Backend (plugin) to be available and returning data.

We start by defining the interface that represents our API:

api/types.ts

src/api/types.ts
import { createApiRef } from '@backstage/core-plugin-api';

export interface HelloApi {
    getHealth(): Promise<{ status: string; }>;
}

export const helloApiRef = createApiRef<HelloApi>({
    id: 'plugin.hello.service', 
});

To ensure we can use these types elsewhere, we import and export them via the index.ts in the same (sub) folder:

api/index.ts

src/api/index.ts
export type { HelloApi } from './types';
export { helloApiRef } from './types';

We do the same from the root of the plugin, again in an index.ts:

index.ts

src/index.ts
export { helloPlugin, EntityHelloContent } from './plugin';
export * from './api';

To ensure we can still run the plugin in isolation, we implement a Mock version we use when we run the plugin in Dev mode.

We do so via the dev folder.

As this implementation also contains JSX code, the extension is now .tsx instead of .ts.

There are two notable changes:

  1. we create a MockHelloClient that implments the HelloApi interface
  2. We wrap our EntityProvider in a TestApiProvider so it retrieve data from our Mock client

dev/index.tsx

src/dev/index.tsx
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { createDevApp } from '@backstage/dev-utils';
import { helloPlugin, EntityHelloContent } from '../src/plugin';
import { HelloApi, helloApiRef } from '../src';
import { TestApiProvider } from '@backstage/test-utils';

const mockEntity: Entity = {
    apiVersion: 'backstage.io/v1alpha1',
    kind: 'Component',
    metadata: {
        name: 'backstage',
        description: 'backstage.io',
        annotations: {
            'backstage.io/kubernetes-id': 'dice-roller',
        },
    },
    spec: {
        lifecycle: 'production',
        type: 'service',
        owner: 'user:guest',
    },
};


class MockHelloClient implements HelloApi {
    async getHealth(): Promise<{ status: string; }> {
        return { status: 'ok'};
    }
}

createDevApp()
    .registerPlugin(helloPlugin)
    .addPage({
        path: '/fixture-1',
        title: 'Fixture 1',
        element: (
        <TestApiProvider apis={[[helloApiRef, new MockHelloClient()]]}>
            <EntityProvider entity={mockEntity}>
                <EntityHelloContent />
            </EntityProvider>
        </TestApiProvider>
        ),
    })
    .render();    

In order to make the Mock implementation available, we need to make two more changes.

  1. Create a React Hook
  2. Use the exported object of this hook in our React Component

hooks/useHelloPluginObjects.ts

src/hooks/useHelloPluginObjects.ts
import { useEffect, useState } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { helloApiRef } from '../api/types';

export const useHelloObjects = () => {
    const [loading, setLoading] = useState<boolean>(true);
    const [status, setStatus] = useState<string>('');
    const [error, setError] = useState<boolean>(false);
    const helloApi = useApi(helloApiRef);
    const getObjects = async () => {
        try {
            const health = await helloApi.getHealth();
            setStatus(health.status);
        } catch (e) {
            setError(true);
        } finally {
            setLoading(false);
        }
    }
    useEffect(() => {
        getObjects();
    });
    return {
        error,
        loading,
        status,
    }
}

Next, we update our Component.

Our Component now has data injected from the React Hook, which is taking care of the Client for us.

ExampleComponent.tsx

src/components/ExampleComponent/ExampleComponent.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useHelloObjects } from '../../hooks/useHelloPluginObjects'

export const ExampleComponent = () => {
    const { entity } = useEntity();
    const { error, loading, status } = useHelloObjects();
    if (loading) {
        return <div>Loading</div>;
    }
    if (error) {
        return <div>Error</div>;
    }
    return (<>
        <div>Hello {entity.metadata.name}</div>
        <div>Status: {status}</div>
    </>);
}

Great work so far, we now have Frontend plugin that can display data from both an (Catalog) Entity and a Backend.

We're not calling the Backend so far, only in Dev mode do we have data.

Next up, let's call our Backend plugin.

Call Actual Backend

We now add a Backend Client, and then update our Plugin to create this client so it retrieves the data from the Backend.

We do this by creating a new file, the client, and updating our plugin.ts.

BackendClient

src/api/HelloBackendClient.ts
import { HelloApi } from './types';
import { DiscoveryApi } from '@backstage/core-plugin-api';

export class HelloBackendClient implements HelloApi {
    private readonly discoveryApi: DiscoveryApi;
    constructor( options: { discoveryApi: DiscoveryApi; } ) {
        this.discoveryApi = options.discoveryApi;
    }
    private async handleResponse(response: Response): Promise<any> {
        if (!response.ok) {
            throw new Error();
        }
        return await response.json();
    }
    async getHealth(): Promise<{ status: string; }> {
        const url = `${await this.discoveryApi.getBaseUrl('hello')}/health`;
        const response = await fetch(url, {
            method: 'GET',
        });

        return await this.handleResponse(response);
    }
}

And the final piece of code, the updated plugin.ts:

Plugin.ts

src/plugin.ts
import { 
    createPlugin, 
    createRoutableExtension,
    createApiFactory,
    discoveryApiRef
} from '@backstage/core-plugin-api';

import { rootCatalogHelloRouteRef } from './routes';

import { HelloBackendClient } from './api/HelloBackendClient';
import { helloApiRef } from './api';

export const helloPlugin = createPlugin({
    id: 'hello',
    apis: [
        createApiFactory({
            api: helloApiRef,
            deps: { discoveryApi: discoveryApiRef },
            factory: ({ discoveryApi }) => new HelloBackendClient({ discoveryApi }),
        }),
    ],
    routes: {
        root: rootCatalogHelloRouteRef,
    },
});

export const EntityHelloContent = helloPlugin.provide(
    createRoutableExtension({
        name: 'EntityHelloContent',
        component: () =>
        import('./components/ExampleComponent').then(m => m.ExampleComponent),
        mountPoint: rootCatalogHelloRouteRef,
    }),
);

Build and Publish to NPMSJS

Before we can wrap it up entirely, we need to publish our frontend plugin.

To do so, we need to compile the TypeScript and build a NPM package we can publish.

Build Package

To compile the type script, we go to the root of the Backstage project, and run the following:

yarn install && yarn tsc

If there are any errors, which there shouldn't, resolve them before continuing.

We can then build the package. First, go back to the plugin folder:

cd plugins/${PLUGIN_NAME}

Then run the build:

yarn build

Once this is done, your plugin should be build and contain a dist folder:

tree

Which gives this result:

.
├── README.md
├── dev
   └── index.tsx
├── dist
   ├── esm
      ├── index-39424543.esm.js
      └── index-39424543.esm.js.map
   ├── index.d.ts
   ├── index.esm.js
   └── index.esm.js.map
├── node_modules
├── package.json
└── src
    ├── api
       ├── HelloBackendClient.ts
       ├── index.ts
       └── types.ts
    ├── components
       └── ExampleComponent
           ├── ExampleComponent.tsx
           └── index.ts
    ├── hooks
       └── useHelloPluginObjects.ts
    ├── index.ts
    ├── plugin.test.ts
    ├── plugin.ts
    ├── routes.ts
    └── setupTests.ts

10 directories, 19 files

Publish Package

Ensure you have an NPMJS.org account and your NPM CLI is logged in:

npm login

Then, from the root of your plugin, run the NPM Publish command:

npm publish --access public

Which should ask you to login to your NPM account:

npm notice
npm notice 📦  @kearos/plugin-hello@0.2.1
npm notice === Tarball Contents ===
npm notice 631B  README.md
npm notice 1.3kB dist/esm/index-39424543.esm.js
npm notice 2.7kB dist/esm/index-39424543.esm.js.map
npm notice 563B  dist/index.d.ts
npm notice 1.8kB dist/index.esm.js
npm notice 3.5kB dist/index.esm.js.map
npm notice 1.5kB package.json
npm notice === Tarball Details ===
npm notice name:          @kearos/plugin-hello
npm notice version:       0.2.1
npm notice filename:      kearos-plugin-hello-0.2.1.tgz
npm notice package size:  4.0 kB
npm notice unpacked size: 12.0 kB
npm notice shasum:        7283d136a2b0....................df0cead7
npm notice integrity:     sha512-8ka4QMDReqI7d[...]zKwxrmmRrY5wg==
npm notice total files:   7
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access
Authenticate your account at:
https://www.npmjs.com/auth/cli/c41ec7fb-659f-4ec8-a1a0-e4cae8d74af0
Press ENTER to open in the browser...

Once you do, it should return with the following:

+ @kearos/plugin-hello@0.2.1

We've completed our work on the frontend plugin.


Last update: 2024-01-17 08:24:08