# Backstage Plugin

> Build a Backstage plugin that shows LevelFour cost data in your developer portal.

Source: https://docs.levelfour.ai/guides/backstage-plugin


A [Backstage](https://backstage.io/) plugin that puts LevelFour cost data in your developer portal. You build both halves:

* **Backend plugin** - Express router that wraps `LevelFourClient` from the `levelfour` npm package
* **Frontend plugin** - React component that fetches data from the backend and renders it

The backend uses Backstage's new backend system (`createBackendPlugin`). The frontend reaches it through `createApiFactory` and `discoveryApi`.

## Backend plugin

### Add your API key to the app config

Add your LevelFour API key to `app-config.yaml`. [Create one](/authentication#api-keys) if you do not have a key yet.

```yaml title="app-config.yaml"
levelfour:
  apiKey: ${LEVELFOUR_API_KEY}
```

> **Tip**
>
> The router only reads, so a `read` key is enough. [Key scopes](/authentication#key-scopes) has the difference.

### Register the router with core services

The plugin entry point registers the HTTP router with Backstage's core services:

```typescript title="plugins/levelfour-backend/src/plugin.ts"
import {
    createBackendPlugin,
    coreServices,
} from "@backstage/backend-plugin-api";
import { createRouter } from "./router";

export const levelfourPlugin = createBackendPlugin({
    pluginId: "levelfour",
    register(env) {
        env.registerInit({
            deps: {
                httpRouter: coreServices.httpRouter,
                logger: coreServices.logger,
                config: coreServices.rootConfig,
            },
            async init({ httpRouter, logger, config }) {
                const router = await createRouter({ logger, config });
                httpRouter.use(router);
            },
        });
    },
});
```

### Wrap the SDK in an Express router

The router reads the API key from config, creates a [`LevelFourClient`](/sdks/typescript), and exposes endpoints at `/api/levelfour/*`:

```typescript title="plugins/levelfour-backend/src/router.ts"
import { Router } from "express";
import { LevelFourClient, LevelFourError } from "levelfour";
import type { LoggerService } from "@backstage/backend-plugin-api";
import type { Config } from "@backstage/config";

interface RouterOptions {
    logger: LoggerService;
    config: Config;
}

export async function createRouter(options: RouterOptions): Promise<Router> {
    const { logger, config } = options;
    const router = Router();

    const apiKey = config.getOptionalString("levelfour.apiKey");
    if (!apiKey) {
        logger.warn("levelfour.apiKey not configured in app-config.yaml");
    }

    const client = new LevelFourClient({ apiKey });

    router.get("/health", (_req, res) => {
        res.json({ status: "ok" });
    });

    router.get("/recommendations/summary", async (_req, res) => {
        try {
            const data = await client.recommendations.getSavingsByProvider();
            res.json(data);
        } catch (err) {
            if (err instanceof LevelFourError) {
                logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`);
                res.status(err.statusCode ?? 500).json({ error: err.message });
                return;
            }
            throw err;
        }
    });

    router.get("/costs/summary", async (_req, res) => {
        try {
            const data = await client.costs.getSummary();
            res.json(data);
        } catch (err) {
            if (err instanceof LevelFourError) {
                logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`);
                res.status(err.statusCode ?? 500).json({ error: err.message });
                return;
            }
            throw err;
        }
    });

    router.get("/recommendations/overview", async (_req, res) => {
        try {
            const data = await client.recommendations.getOverview();
            res.json(data);
        } catch (err) {
            if (err instanceof LevelFourError) {
                logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`);
                res.status(err.statusCode ?? 500).json({ error: err.message });
                return;
            }
            throw err;
        }
    });

    return router;
}
```

> **Note**
>
> A missing `levelfour.apiKey` logs a warning and the plugin starts anyway. The router builds the client without an explicit key, so nothing fails at startup and any gap shows up later, on a request that reaches the LevelFour API.

### Declare the backend dependencies

```json title="plugins/levelfour-backend/package.json"
{
    "dependencies": {
        "@backstage/backend-plugin-api": "^1.3.0",
        "express": "^4.21.0",
        "levelfour": "^1.3.0"
    }
}
```

### Add the plugin to your backend

In your Backstage backend's `src/index.ts`:

```typescript
backend.add(import("@internal/plugin-levelfour-backend"));
```

## Frontend plugin

### Define the API client

The frontend talks to the backend plugin through a typed client:

```typescript title="plugins/levelfour/src/api.ts"
import {
    createApiRef,
    type DiscoveryApi,
    type FetchApi,
} from "@backstage/core-plugin-api";

export interface LevelFourApi {
    getRecommendationsSummary(): Promise<unknown>;
    getCostsSummary(): Promise<unknown>;
    getRecommendationsOverview(): Promise<unknown>;
}

export const levelFourApiRef = createApiRef<LevelFourApi>({
    id: "plugin.levelfour.service",
});

export class LevelFourApiClient implements LevelFourApi {
    private readonly discoveryApi: DiscoveryApi;
    private readonly fetchApi: FetchApi;

    constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) {
        this.discoveryApi = options.discoveryApi;
        this.fetchApi = options.fetchApi;
    }

    private async fetch(path: string): Promise<unknown> {
        const baseUrl = await this.discoveryApi.getBaseUrl("levelfour");
        const response = await this.fetchApi.fetch(`${baseUrl}${path}`);
        if (!response.ok) {
            throw new Error(`LevelFour API error: ${response.status} ${response.statusText}`);
        }
        return response.json();
    }

    async getRecommendationsSummary(): Promise<unknown> {
        return this.fetch("/recommendations/summary");
    }

    async getCostsSummary(): Promise<unknown> {
        return this.fetch("/costs/summary");
    }

    async getRecommendationsOverview(): Promise<unknown> {
        return this.fetch("/recommendations/overview");
    }
}
```

> **Warning**
>
> `getBaseUrl("levelfour")` has to match the backend plugin's `pluginId`, which is what puts the routes at `/api/levelfour/*`. Change one without the other and the dashboard fetches a URL that looks right and gets a 404 back.

### Create the plugin and its page

Register the API factory and routable extension:

```typescript title="plugins/levelfour/src/plugin.ts"
import {
    createPlugin,
    createApiFactory,
    createRoutableExtension,
    discoveryApiRef,
    fetchApiRef,
} from "@backstage/core-plugin-api";
import { levelFourApiRef, LevelFourApiClient } from "./api";
import { rootRouteRef } from "./routes";

export const levelfourPlugin = createPlugin({
    id: "levelfour",
    apis: [
        createApiFactory({
            api: levelFourApiRef,
            deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
            factory: ({ discoveryApi, fetchApi }) =>
                new LevelFourApiClient({ discoveryApi, fetchApi }),
        }),
    ],
    routes: {
        root: rootRouteRef,
    },
});

export const LevelFourPage = levelfourPlugin.provide(
    createRoutableExtension({
        name: "LevelFourPage",
        component: () =>
            import("./components/CostDashboard").then((m) => m.CostDashboard),
        mountPoint: rootRouteRef,
    }),
);
```

### Render the dashboard

The dashboard component fetches recommendations and cost data in parallel:

```tsx title="plugins/levelfour/src/components/CostDashboard.tsx"
import React, { useEffect, useState } from "react";
import { useApi } from "@backstage/core-plugin-api";
import {
    Header,
    Page,
    Content,
    InfoCard,
    Progress,
} from "@backstage/core-components";
import { levelFourApiRef } from "../api";

export const CostDashboard = () => {
    const api = useApi(levelFourApiRef);
    const [recommendations, setRecommendations] = useState<unknown>(null);
    const [costs, setCosts] = useState<unknown>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        Promise.all([
            api.getRecommendationsSummary(),
            api.getCostsSummary(),
        ])
            .then(([recs, costs]) => {
                setRecommendations(recs);
                setCosts(costs);
            })
            .catch((err) => setError(err.message))
            .finally(() => setLoading(false));
    }, [api]);

    if (loading) return <Progress />;

    if (error) {
        return (
            <Page themeId="tool">
                <Header title="Cloud Cost Optimization" />
                <Content>
                    <InfoCard title="Error">{error}</InfoCard>
                </Content>
            </Page>
        );
    }

    return (
        <Page themeId="tool">
            <Header title="Cloud Cost Optimization" subtitle="Powered by LevelFour" />
            <Content>
                <InfoCard title="Savings by Provider">
                    <pre>{JSON.stringify(recommendations, null, 2)}</pre>
                </InfoCard>
                <InfoCard title="Cost Summary">
                    <pre>{JSON.stringify(costs, null, 2)}</pre>
                </InfoCard>
            </Content>
        </Page>
    );
};
```

### Add the route to your app

In your Backstage app's `App.tsx`, add the route:

```tsx title="App.tsx"
import { LevelFourPage } from "@internal/plugin-levelfour";

<Route path="/levelfour" element={<LevelFourPage />} />
```

## Verify it works

With both plugins registered, check the backend first:

```bash
curl http://localhost:7007/api/levelfour/health
curl http://localhost:7007/api/levelfour/recommendations/summary
curl http://localhost:7007/api/levelfour/costs/summary
```

> **Success**
>
> `/health` answering `{ "status": "ok" }` means the router is mounted. The other two go out to the LevelFour API, so JSON back from `/recommendations/summary` is the whole path working, config through SDK.

> **Error**
>
> A body of `{ "error": ... }` is the LevelFour API's own failure rather than a Backstage one. The router forwards it with `res.status(err.statusCode ?? 500)`, so you get the upstream status, or a 500 when the error carries none. On a 4xx, check the key and the account. [Error handling](/error-handling) has the class behind it.

Then open `http://localhost:3000/levelfour` to see the cost dashboard.

## Next

* [TypeScript SDK](/sdks/typescript) is the `levelfour` package the router imports: installing it, constructing the client and the constructor options
* [Resources](/resources) has every method the router calls, with its parameters and response shapes
* [Authentication](/authentication) is where the key in `app-config.yaml` comes from, and what a `read` scope covers
* [Error handling](/error-handling) is the hierarchy behind `LevelFourError` and its `statusCode`
* [Backstage Cost Insights plugin](https://github.com/backstage/community-plugins/tree/main/workspaces/cost-insights) follows the same patterns. This one calls the LevelFour API through the `levelfour` npm package.
