diff --git a/.changeset/silver-rice-pull.md b/.changeset/silver-rice-pull.md
new file mode 100644
index 0000000000..1b4f966fa4
--- /dev/null
+++ b/.changeset/silver-rice-pull.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-module-new': patch
+---
+
+Updated frontend-plugin template to provide a todo list visualization compatible with the backend plugin.
diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt
index 0a96735646..f6cbd31439 100644
--- a/.github/vale/config/vocabularies/Backstage/accept.txt
+++ b/.github/vale/config/vocabularies/Backstage/accept.txt
@@ -568,6 +568,7 @@ VMware
Vodafone
VPCs
VSCode
+walkthrough
walkthroughs
Wayfair
Weaveworks
diff --git a/docs/golden-path/plugins/frontend/001-first-steps.md b/docs/golden-path/plugins/frontend/001-first-steps.md
index 313a990b62..5ece316d9f 100644
--- a/docs/golden-path/plugins/frontend/001-first-steps.md
+++ b/docs/golden-path/plugins/frontend/001-first-steps.md
@@ -5,12 +5,95 @@ title: How to scaffold a new plugin?
description: How to scaffold a new Backstage frontend plugin using the CLI
---
-Running `yarn new` -> `frontend-plugin`.
+# Scaffolding a new plugin
+
+Create a new frontend plugin package by running the following command in your
+Backstage repository's root directory:
+
+```sh
+yarn new --select frontend-plugin --option pluginId=todo --option owner=
+```
+
+This creates a new NPM package named something like `@internal/plugin-todo`,
+depending on the flags passed to the `new` command and your settings in the root
+`package.json`. For more options, see
+[the CLI docs](../../../tooling/cli/03-commands.md#new).
+
+Creating the plugin takes a moment. Once the command finishes, a new folder
+appears at `plugins/todo` (the path depends on the plugin ID you chose) with
+a structure like this:
+
+```
+plugins/todo/
+├── dev/ # Standalone dev server setup
+├── src/
+│ ├── components/
+│ │ ├── TodoList/
+│ │ └── TodoPage/
+│ └── ... # Plugin definition, routes, tests
+└── package.json
+```
## What did we create?
-
+Here is a quick overview of the key files:
+
+- **`src/plugin.tsx`** — The main plugin definition. This is where the plugin
+ is created using `createFrontendPlugin` and where page extensions are
+ registered using `PageBlueprint`.
+
+- **`src/plugin.test.ts`** — Tests for the plugin definition. Verifies that
+ the plugin and its extensions are created correctly.
+
+- **`src/routes.ts`** — Route reference definitions used for navigation between plugins.
+
+- **`src/index.ts`** — The package entry point, which exports the plugin as
+ the default export.
+
+- **`src/components/TodoPage/`** — The main page component. It fetches todo
+ items from the backend and renders them using the `TodoList` component.
+
+- **`src/components/TodoList/`** — A presentational component that renders a
+ table of todo items using `@backstage/ui`.
+
+- **`dev/index.tsx`** — A standalone development app that loads only your
+ plugin. Run `yarn start` from the plugin directory to launch it.
+
+- **`package.json`** — Notice the `backstage.role` field is set to
+ `"frontend-plugin"`. This tells the Backstage tooling how to build and
+ treat the package.
+
+## Verifying the plugin
+
+If your app has feature discovery enabled (the default), your plugin is
+automatically picked up. If you are not using feature discovery, see the
+[installation docs](../../../frontend-system/building-apps/05-installing-plugins.md)
+for how to manually add the plugin to your app. Start the full app from the
+repository root:
+
+```sh
+yarn start
+```
+
+Then navigate to `http://localhost:3000/todo` in your browser (the path
+matches the plugin ID you chose). You see the todo page with a header and
+example data. If you also have the backend todo plugin running, the page
+displays your real todo items instead.
+
+Run the plugin in isolation using its standalone development server:
+
+```sh
+yarn workspace @internal/plugin-todo start
+```
## Common issues
-
+- **Plugin page not showing up.** Verify that `app.packages` is set to `all`
+ in your `app-config.yaml`. If you use include/exclude filters, make sure your
+ plugin package is not excluded.
+- **`yarn new --select frontend-plugin --option pluginId=todo --option owner=` fails during installation.** Make sure
+ you have run `yarn install` in the repository root first and that your
+ Node.js version matches the one required by the project.
+- **TypeScript errors after scaffolding.** Run `yarn tsc` from the repository
+ root to check for type errors. A fresh scaffold should compile cleanly — if
+ not, try running `yarn install` again.
diff --git a/docs/golden-path/plugins/frontend/002-poking-around.md b/docs/golden-path/plugins/frontend/002-poking-around.md
index 33223019d0..0987251587 100644
--- a/docs/golden-path/plugins/frontend/002-poking-around.md
+++ b/docs/golden-path/plugins/frontend/002-poking-around.md
@@ -5,14 +5,93 @@ title: 002 - Poking around
description: Exploring the default frontend plugin structure and components
---
-Our frontend TODO plugin is a bit more simplistic than the backend one. We need to implement a new UI to replace the example components we have.
+Walk through the code that `yarn new --select frontend-plugin --option pluginId=todo --option owner=` generated.
-Let's use this React component to start. Copy this to `plugins/todo/src/components/TodoList.tsx`.
+## Plugin definition
+
+Open `plugins/todo/src/plugin.tsx`. This is the entry point for the plugin:
```tsx
-// todo
+import {
+ createFrontendPlugin,
+ PageBlueprint,
+} from '@backstage/frontend-plugin-api';
+
+import { rootRouteRef } from './routes';
+
+export const page = PageBlueprint.make({
+ params: {
+ path: '/todo',
+ routeRef: rootRouteRef,
+ loader: () => import('./components/TodoPage').then(m => ),
+ },
+});
+
+export const todoPlugin = createFrontendPlugin({
+ pluginId: 'todo',
+ extensions: [page],
+ routes: {
+ root: rootRouteRef,
+ },
+});
```
-### Data Mocking
+- `createFrontendPlugin` registers the plugin with Backstage.
+- `PageBlueprint.make` defines a page extension — a route in the app that
+ lazy-loads the `TodoPage` component.
+- `rootRouteRef` is a route reference that other plugins can use to link to
+ your plugin's page.
-You already have a backend with dynamic data. Let's start a little smaller. Using hard coded data can be a great way to iterate quickly.
+## The TodoPage component
+
+Open `plugins/todo/src/components/TodoPage/TodoPage.tsx`. This component
+fetches data from the backend and renders it:
+
+```tsx
+const { value: todos, loading, error } = useTodos();
+```
+
+The `useTodos` hook uses Backstage's **`fetchApiRef`** to request
+`plugin://todo/todos`.
+
+- **`fetchApiRef`** wraps the browser `fetch`, automatically injects
+ authentication credentials, and resolves the `plugin://` URL scheme to the
+ correct backend plugin endpoint (for example,
+ `http://localhost:7007/api/todo/todos`).
+
+If the backend is not running, the page falls back to example data so that
+the plugin still renders correctly out of the box.
+
+## The TodoList component
+
+Open `plugins/todo/src/components/TodoList/TodoList.tsx`. This is a
+presentational component that receives a list of todos as props and renders
+them in a `Table` from `@backstage/ui`.
+
+The `TodoItem` type matches the shape returned by the backend plugin:
+
+```ts
+export type TodoItem = {
+ title: string;
+ id: string;
+ createdBy: string;
+ createdAt: string;
+};
+```
+
+## Understanding the page structure
+
+The scaffolded plugin uses components from `@backstage/ui` and
+`@backstage/core-components` to give the page a consistent look and feel
+across all Backstage plugins:
+
+- The page's top bar is typically provided by the surrounding `PageLayout`
+ (commonly `PluginHeader` in the default app), rather than by a custom
+ `Header` inside the page component.
+- `Container` is the main content area of the page (from `@backstage/ui`).
+- `Table` renders a data table with column configuration (from `@backstage/ui`).
+- `Progress` shows a loading indicator (from `@backstage/core-components`).
+
+Keeping your plugin visually consistent with the rest of Backstage is important
+— users should feel at home regardless of which plugin they are interacting
+with.
diff --git a/docs/golden-path/plugins/frontend/003-dynamic-config.md b/docs/golden-path/plugins/frontend/003-dynamic-config.md
index 8f3b7ff1ef..4732e328b2 100644
--- a/docs/golden-path/plugins/frontend/003-dynamic-config.md
+++ b/docs/golden-path/plugins/frontend/003-dynamic-config.md
@@ -5,26 +5,116 @@ title: 003 - Dynamic Config
description: How to use dynamic configuration to control frontend plugin components
---
-Your plugin should have been generated by default for the New Frontend System which is config-first. That means you can easily control your frontend components through your `app-config.yaml`.
+Your plugin was generated for the frontend system, which is config-first.
+That means you can control frontend components through `app-config.yaml`
+without changing any code.
-Let's try this quickly by disabling our entire TODO page,
+## Disabling an extension
-```yaml
-# TODO
+Every extension in the frontend system can be toggled on or off through
+configuration. To disable the todo page entirely, add the following to your
+`app-config.yaml`:
+
+```yaml title="app-config.yaml"
+app:
+ extensions:
+ - 'page:todo': false
```
-We can also do really cool things like provide React props directly through config. Let's try moving our hard coded list of TODOs to config instead,
+Start the app and try navigating to `/todo` — you get a "page not found"
+response. Remove the line (or set it to `true`) to bring it back.
+
+## Configuring an extension
+
+Every extension blueprint supports its own set of configuration options that
+adopters can set through `app-config.yaml`. `PageBlueprint` supports `path`
+and `title` out of the box. To change the page title, add the following:
+
+```yaml title="app-config.yaml"
+app:
+ extensions:
+ - page:todo:
+ config:
+ title: My Custom Todo List
+```
+
+Restart the app and you should see "My Custom Todo List" as the page title.
+No code changes needed — the `PageBlueprint` reads the `title` config and
+passes it to the page header automatically.
+
+## Adding custom configuration
+
+When the built-in config options are not enough, you can define your own
+config schema. Values are validated automatically and passed to your
+extension factory so that your components never need to read raw
+configuration directly.
+
+For example, let's add a configurable subtitle. In `plugin.tsx`, switch
+from `PageBlueprint.make` to `PageBlueprint.makeWithOverrides` and declare
+a config schema:
```tsx
-// todo
+export const page = PageBlueprint.makeWithOverrides({
+ config: {
+ schema: {
+ subtitle: z => z.string().optional(),
+ },
+ },
+ factory(origFactory, { config }) {
+ return origFactory({
+ path: '/todo',
+ routeRef: rootRouteRef,
+ loader: () =>
+ import('./components/TodoPage').then(m => (
+
+ )),
+ });
+ },
+});
```
-and the config,
+Then update `TodoPage` to accept the new prop and render it:
-```yaml
-# TODO
+```tsx
+export function TodoPage({ subtitle }: { subtitle?: string }) {
+ // ... existing component code
+ return (
+
+ {subtitle && {subtitle}}
+ {/* rest of the page */}
+
+ );
+}
```
-### Why does this work?
+Adopters can now set the subtitle in their `app-config.yaml`:
-
+```yaml title="app-config.yaml"
+app:
+ extensions:
+ - page:todo:
+ config:
+ subtitle: Things to get done today
+```
+
+The value flows from configuration, through the schema validation, into the
+factory function, and finally into the component as a prop — no `configApiRef`
+needed.
+
+## Why does this work?
+
+The frontend system treats configuration as a first-class concept.
+Each extension is registered with the app under a unique ID (for example,
+`page:todo`). The app reads the `app.extensions` section of the configuration
+to decide which extensions to enable, disable, or reconfigure.
+
+Extension blueprints declare a `config.schema` using
+[Zod](https://zod.dev/) validators. When the app starts, the framework
+parses and validates the configuration against the schema, then passes the
+result to the extension's factory function. This means your components
+receive typed, validated values instead of reading raw configuration
+strings at runtime.
+
+This config-first approach means that adopters of your plugin can customize
+its behavior without forking the code — they only need to adjust their
+configuration files.
diff --git a/docs/golden-path/plugins/frontend/004-http-client.md b/docs/golden-path/plugins/frontend/004-http-client.md
index 00678df1d6..cb7f3fd67a 100644
--- a/docs/golden-path/plugins/frontend/004-http-client.md
+++ b/docs/golden-path/plugins/frontend/004-http-client.md
@@ -5,14 +5,114 @@ title: 004 - HTTP Client
description: How to build an HTTP client for your frontend plugin to fetch backend data
---
-Now, let's really make our page dynamic. We'll start by writing an HTTP client by hand.
+The scaffolded `TodoPage` already fetches data from the backend. Let's look at
+how that works and how you can extend it.
+
+## How the scaffolded code works
+
+Open `plugins/todo/src/components/TodoPage/TodoPage.tsx` and look at the
+`useTodos` hook:
```tsx
-class TodoClient {
- // TODO
+function useTodos() {
+ const { fetch } = useApi(fetchApiRef);
+
+ return useAsync(async (): Promise => {
+ const response = await fetch(`plugin://todo/todos`);
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch todos: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+ return data.items;
+ }, [fetch]);
}
```
-## OpenAPI Generated Clients
+Here, we're using Backstage's `fetchApi` which wraps the browser `fetch` and automatically does 2 things,
-You can also skip a step and ensure your frontend and backend stay in sync by generating the client from an OpenAPI schema.
+1. Injects authentication credentials - you don't need to attach any `Authorization` headers manually.
+2. Resolves `plugin://` URL schemes to the real plugin URL for your instance.
+
+The `useAsync` hook from `react-use` runs the async function on mount and
+returns `{ value, loading, error }`, which the component uses to show a
+loading spinner, example todo items if the backend request fails, or the
+fetched todo list.
+
+## Trying it out
+
+Make sure both the frontend and backend are running (`yarn start` from the
+repository root starts both). Navigate to `http://localhost:3000/todo` and
+you should see todos fetched from your backend.
+
+:::tip
+You can create todos using `curl` as described in the
+[backend golden path](../backend/002-poking-around.md), then refresh the
+frontend page to see them appear.
+:::
+
+## Extracting a client class
+
+
+
+For plugins with several endpoints, extracting a dedicated client class
+keeps your components focused on rendering. Create
+`plugins/todo/src/api/TodoClient.ts`:
+
+```ts
+import { FetchApi } from '@backstage/frontend-plugin-api';
+import type { TodoItem } from '../components/TodoList';
+
+export class TodoClient {
+ readonly #fetchApi: FetchApi;
+
+ constructor(options: { fetchApi: FetchApi }) {
+ this.#fetchApi = options.fetchApi;
+ }
+
+ async listTodos(): Promise {
+ const response = await this.#fetchApi.fetch(`plugin://todo/todos`);
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch todos: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+ return data.items;
+ }
+
+ async createTodo(title: string): Promise {
+ const response = await this.#fetchApi.fetch(`plugin://todo/todos`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title }),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to create todo: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ return response.json();
+ }
+}
+```
+
+This is optional for the scaffolded example, but becomes valuable as
+your plugin grows.
+
+## OpenAPI generated clients
+
+You can also keep your frontend and backend in sync by generating the
+client from an OpenAPI schema. If your backend plugin exposes an OpenAPI
+spec (see the
+[backend golden path](../backend/001-first-steps.md) for details),
+you can generate a type-safe client that updates automatically whenever the
+API changes. This approach reduces the risk of the frontend and backend
+drifting apart over time.
diff --git a/docs/golden-path/plugins/frontend/005-testing.md b/docs/golden-path/plugins/frontend/005-testing.md
index 6a672998aa..0767d8c073 100644
--- a/docs/golden-path/plugins/frontend/005-testing.md
+++ b/docs/golden-path/plugins/frontend/005-testing.md
@@ -5,12 +5,108 @@ title: 005 - Testing
description: How to write tests for your Backstage frontend plugin components
---
-Everyone's favorite part! Let's make sure our components continue to work even when we're not able to validate the changes.
+The scaffolded plugin comes with tests already in place. Let's walk through
+how they work and how to extend them.
-## Unit Tests
+## Unit tests
-Use Jest + RTL + MSW v2.
+Backstage frontend plugins use **Jest** as the test runner,
+**React Testing Library** for rendering components, and **MSW**
+(Mock Service Worker) for intercepting HTTP requests.
-## Integration Tests
+### TodoList tests
-Use Playwright.
+Open `plugins/todo/src/components/TodoList/TodoList.test.tsx`. The `TodoList`
+component is presentational, so testing it is straightforward — pass in data
+and verify it renders:
+
+```tsx
+await renderInTestApp();
+
+expect(screen.getByText('First task')).toBeInTheDocument();
+```
+
+`renderInTestApp` from `@backstage/frontend-test-utils` sets up a minimal
+Backstage app context around the component, providing all the standard APIs
+that components might depend on.
+
+### TodoPage tests
+
+Open `plugins/todo/src/components/TodoPage/TodoPage.test.tsx`. The `TodoPage`
+component fetches data from the backend, so the tests use MSW to intercept
+HTTP requests and return test data:
+
+```tsx
+const server = setupServer();
+registerMswTestHooks(server);
+
+it('renders todos from the backend', async () => {
+ server.use(
+ rest.get('*/api/todo/todos', (req, res, ctx) =>
+ res(
+ ctx.json({
+ items: [{ id: '1', title: 'Mocked task' /* ... */ }],
+ }),
+ ),
+ ),
+ );
+
+ await renderInTestApp();
+
+ expect(await screen.findByText('Mocked task')).toBeInTheDocument();
+});
+```
+
+A few things to note:
+
+- **`registerMswTestHooks`** sets up and tears down the MSW server around
+ each test, so handlers do not leak between tests.
+- **`screen.findByText`** returns a promise that waits for the element to
+ appear. This handles the asynchronous nature of data fetching without
+ needing explicit `waitFor` calls.
+- The URL pattern `*/api/todo/todos` matches regardless of the host, which
+ keeps the test independent of the discovery API's resolved base URL.
+
+The tests also cover the error case — when the backend returns a 500 status,
+the component falls back to rendering example todo items instead of
+displaying an error panel.
+
+### Running the tests
+
+From the repository root:
+
+```sh
+yarn test plugins/todo
+```
+
+### Writing additional tests
+
+As you add features to your plugin, follow the same patterns:
+
+- For presentational components, pass props and assert on rendered output.
+- For components that fetch data, use MSW to mock the HTTP responses.
+- Prefer `screen.findByText` over `waitFor` for async assertions.
+- Test both the success and error paths.
+
+## Integration tests
+
+For end-to-end validation, you can use **Playwright** to test your plugin in
+a real browser against a running Backstage instance. Integration tests catch
+issues that unit tests cannot, such as routing problems, CSS regressions, or
+issues with the full API round-trip.
+
+A basic Playwright test for the todo page might look like this:
+
+```ts
+import { test, expect } from '@playwright/test';
+
+test('todo page shows the todo list', async ({ page }) => {
+ await page.goto('/todo');
+ await expect(page.getByText('Welcome to todo!')).toBeVisible();
+ await expect(page.getByRole('table')).toBeVisible();
+});
+```
+
+Integration tests are most valuable for critical user flows. For most
+component-level validation, the unit testing approach described above
+provides faster feedback with less setup.
diff --git a/docs/golden-path/plugins/frontend/__meta__.md b/docs/golden-path/plugins/frontend/__meta__.md
index 088d72ce90..892dd235b3 100644
--- a/docs/golden-path/plugins/frontend/__meta__.md
+++ b/docs/golden-path/plugins/frontend/__meta__.md
@@ -14,19 +14,17 @@ How to handle common errors.
## First steps with the new plugin
-### Creating a todo plugin
+### Exploring the scaffolded code
-We're going to be creating the frontend for a todo list plugin. We want the user to be able to create todos for themselves and show the user their current list of todos.
-
-To start, we'll use a list of mocked data.
+Walk through the generated TodoPage and TodoList components, the plugin definition, and how they fit together.
### Controlling your component dynamically
-Update the mocked data to be controlled by config.
+Show how to disable extensions and configure them dynamically using a PageBlueprint config schema and validated config passed into factories (without using configApiRef).
### HTTP API
-We want our todo plugin to reach the backend that we implemented in [the backend plugin Golden Path](../backend/001-first-steps.md). Let's write a client to do this (or use OpenAPI to generate a client for us).
+The scaffolded plugin already fetches from the backend. Walk through how discoveryApiRef and fetchApiRef work together, and show how to extract a client class.
### Testing
diff --git a/docs/golden-path/plugins/index.md b/docs/golden-path/plugins/index.md
index 43ffe612e8..c1a926f32f 100644
--- a/docs/golden-path/plugins/index.md
+++ b/docs/golden-path/plugins/index.md
@@ -32,3 +32,4 @@ To start, this guide will walk through creating a backend plugin. You'll get you
- [Why build plugins?](./why-build-plugins.md)
- [Sustainable plugin development](./sustainable-plugin-development.md)
- [Golden path: Backend plugins](./backend/001-first-steps.md)
+- [Golden path: Frontend plugins](./frontend/001-first-steps.md)
diff --git a/packages/cli-module-new/src/lib/version.ts b/packages/cli-module-new/src/lib/version.ts
index 27e63bafdf..fcfde359c1 100644
--- a/packages/cli-module-new/src/lib/version.ts
+++ b/packages/cli-module-new/src/lib/version.ts
@@ -49,6 +49,7 @@ import { version as frontendTestUtils } from '../../../frontend-test-utils/packa
import { version as testUtils } from '../../../test-utils/package.json';
import { version as theme } from '../../../theme/package.json';
import { version as types } from '../../../types/package.json';
+import { version as ui } from '../../../ui/package.json';
import { version as authBackend } from '../../../../plugins/auth-backend/package.json';
import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json';
import { version as catalogNode } from '../../../../plugins/catalog-node/package.json';
@@ -74,6 +75,7 @@ export const packageVersions: Record = {
'@backstage/test-utils': testUtils,
'@backstage/theme': theme,
'@backstage/types': types,
+ '@backstage/ui': ui,
'@backstage/plugin-scaffolder-node': scaffolderNode,
'@backstage/plugin-scaffolder-node-test-utils': scaffolderNodeTestUtils,
'@backstage/plugin-auth-backend': authBackend,
diff --git a/packages/cli-module-new/templates/frontend-plugin/README.md.hbs b/packages/cli-module-new/templates/frontend-plugin/README.md.hbs
index 3f1ff3e4f7..4da2dcdb54 100644
--- a/packages/cli-module-new/templates/frontend-plugin/README.md.hbs
+++ b/packages/cli-module-new/templates/frontend-plugin/README.md.hbs
@@ -10,7 +10,7 @@ Your plugin has been added to the app in this repository, meaning you'll be able
to access it by running `yarn start` in the root directory, and then navigating
to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
-This plugin is built with Backstage's [new frontend
+This plugin is built with Backstage's [frontend
system](https://backstage.io/docs/frontend-system/architecture/index), and you
can find more information about building plugins in the [plugin builder
documentation](https://backstage.io/docs/frontend-system/building-plugins/index).
diff --git a/packages/cli-module-new/templates/frontend-plugin/package.json.hbs b/packages/cli-module-new/templates/frontend-plugin/package.json.hbs
index 955c6077b8..9f00ae8539 100644
--- a/packages/cli-module-new/templates/frontend-plugin/package.json.hbs
+++ b/packages/cli-module-new/templates/frontend-plugin/package.json.hbs
@@ -25,9 +25,7 @@
"@backstage/core-components": "{{versionQuery '@backstage/core-components'}}",
"@backstage/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}",
"@backstage/theme": "{{versionQuery '@backstage/theme'}}",
- "@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
- "@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
- "@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.61'}}",
+ "@backstage/ui": "{{versionQuery '@backstage/ui'}}",
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
},
"peerDependencies": {
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
deleted file mode 100644
index d1a8a93d0b..0000000000
--- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
+++ /dev/null
@@ -1,28 +0,0 @@
-import { ExampleComponent } from './ExampleComponent';
-import { rest } from 'msw';
-import { setupServer } from 'msw/node';
-import { screen } from '@testing-library/react';
-import {
- registerMswTestHooks,
- renderInTestApp,
-} from '@backstage/frontend-test-utils';
-
-describe('ExampleComponent', () => {
- const server = setupServer();
- // Enable sane handlers for network requests
- registerMswTestHooks(server);
-
- // setup mock response
- beforeEach(() => {
- server.use(
- rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
- );
- });
-
- it('should render', async () => {
- await renderInTestApp();
- expect(
- screen.getByText('Welcome to {{pluginId}}!'),
- ).toBeInTheDocument();
- });
-});
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
deleted file mode 100644
index d117fad131..0000000000
--- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Typography, Grid } from '@material-ui/core';
-import {
- InfoCard,
- Header,
- Page,
- Content,
- ContentHeader,
- HeaderLabel,
- SupportButton,
-} from '@backstage/core-components';
-import { ExampleFetchComponent } from '../ExampleFetchComponent';
-
-export const ExampleComponent = () => (
-
-
-
-
- A description of your plugin goes here.
-
-
-
-
-
- All content should be wrapped in a card like this.
-
-
-
-
-
-
-
-
-
-);
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts
deleted file mode 100644
index 8b8437521b..0000000000
--- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { ExampleComponent } from './ExampleComponent';
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
deleted file mode 100644
index c9b553287f..0000000000
--- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-import { renderInTestApp } from '@backstage/frontend-test-utils';
-import { ExampleFetchComponent } from './ExampleFetchComponent';
-
-describe('ExampleFetchComponent', () => {
- it('renders the user table', async () => {
- const { getAllByText, getByAltText, getByText, findByRole } =
- await renderInTestApp();
-
- // Wait for the table to render
- const table = await findByRole('table');
- const nationality = getAllByText('GB');
- // Assert that the table contains the expected user data
- expect(table).toBeInTheDocument();
- expect(getByAltText('Carolyn')).toBeInTheDocument();
- expect(getByText('Carolyn Moore')).toBeInTheDocument();
- expect(getByText('carolyn.moore@example.com')).toBeInTheDocument();
- expect(nationality[0]).toBeInTheDocument();
- });
-});
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
deleted file mode 100644
index bd6bd9d75b..0000000000
--- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
+++ /dev/null
@@ -1,308 +0,0 @@
-import { makeStyles } from '@material-ui/core/styles';
-import {
- Table,
- TableColumn,
- Progress,
- ResponseErrorPanel,
-} from '@backstage/core-components';
-import useAsync from 'react-use/lib/useAsync';
-
-export const exampleUsers = {
- results: [
- {
- gender: 'female',
- name: {
- title: 'Miss',
- first: 'Carolyn',
- last: 'Moore',
- },
- email: 'carolyn.moore@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Carolyn',
- nat: 'GB',
- },
- {
- gender: 'female',
- name: {
- title: 'Ms',
- first: 'Esma',
- last: 'Berberoğlu',
- },
- email: 'esma.berberoglu@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Esma',
- nat: 'TR',
- },
- {
- gender: 'female',
- name: {
- title: 'Ms',
- first: 'Isabella',
- last: 'Rhodes',
- },
- email: 'isabella.rhodes@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella',
- nat: 'GB',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Derrick',
- last: 'Carter',
- },
- email: 'derrick.carter@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Derrick',
- nat: 'IE',
- },
- {
- gender: 'female',
- name: {
- title: 'Miss',
- first: 'Mattie',
- last: 'Lambert',
- },
- email: 'mattie.lambert@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mattie',
- nat: 'AU',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Mijat',
- last: 'Rakić',
- },
- email: 'mijat.rakic@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mijat',
- nat: 'RS',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Javier',
- last: 'Reid',
- },
- email: 'javier.reid@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Javier',
- nat: 'US',
- },
- {
- gender: 'female',
- name: {
- title: 'Ms',
- first: 'Isabella',
- last: 'Li',
- },
- email: 'isabella.li@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella',
- nat: 'CA',
- },
- {
- gender: 'female',
- name: {
- title: 'Mrs',
- first: 'Stephanie',
- last: 'Garrett',
- },
- email: 'stephanie.garrett@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Stephanie',
- nat: 'AU',
- },
- {
- gender: 'female',
- name: {
- title: 'Ms',
- first: 'Antonia',
- last: 'Núñez',
- },
- email: 'antonia.nunez@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Antonia',
- nat: 'ES',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Donald',
- last: 'Young',
- },
- email: 'donald.young@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Donald',
- nat: 'US',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Iegor',
- last: 'Holodovskiy',
- },
- email: 'iegor.holodovskiy@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Iegor',
- nat: 'UA',
- },
- {
- gender: 'female',
- name: {
- title: 'Madame',
- first: 'Jessica',
- last: 'David',
- },
- email: 'jessica.david@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jessica',
- nat: 'CH',
- },
- {
- gender: 'female',
- name: {
- title: 'Ms',
- first: 'Eve',
- last: 'Martinez',
- },
- email: 'eve.martinez@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Eve',
- nat: 'FR',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Caleb',
- last: 'Silva',
- },
- email: 'caleb.silva@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Caleb',
- nat: 'US',
- },
- {
- gender: 'female',
- name: {
- title: 'Miss',
- first: 'Marcia',
- last: 'Jenkins',
- },
- email: 'marcia.jenkins@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Marcia',
- nat: 'US',
- },
- {
- gender: 'female',
- name: {
- title: 'Mrs',
- first: 'Mackenzie',
- last: 'Jones',
- },
- email: 'mackenzie.jones@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mackenzie',
- nat: 'NZ',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Jeremiah',
- last: 'Gutierrez',
- },
- email: 'jeremiah.gutierrez@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jeremiah',
- nat: 'AU',
- },
- {
- gender: 'female',
- name: {
- title: 'Ms',
- first: 'Luciara',
- last: 'Souza',
- },
- email: 'luciara.souza@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Luciara',
- nat: 'BR',
- },
- {
- gender: 'male',
- name: {
- title: 'Mr',
- first: 'Valgi',
- last: 'da Cunha',
- },
- email: 'valgi.dacunha@example.com',
- picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Valgi',
- nat: 'BR',
- },
- ],
-};
-
-const useStyles = makeStyles({
- avatar: {
- height: 32,
- width: 32,
- borderRadius: '50%',
- },
-});
-
-type User = {
- gender: string; // "male"
- name: {
- title: string; // "Mr",
- first: string; // "Duane",
- last: string; // "Reed"
- };
- email: string; // "duane.reed@example.com"
- picture: string; // "https://api.dicebear.com/6.x/open-peeps/svg?seed=Duane"
- nat: string; // "AU"
-};
-
-type DenseTableProps = {
- users: User[];
-};
-
-export const DenseTable = ({ users }: DenseTableProps) => {
- const classes = useStyles();
-
- const columns: TableColumn[] = [
- { title: 'Avatar', field: 'avatar' },
- { title: 'Name', field: 'name' },
- { title: 'Email', field: 'email' },
- { title: 'Nationality', field: 'nationality' },
- ];
-
- const data = users.map(user => {
- return {
- avatar: (
-
- ),
- name: `${user.name.first} ${user.name.last}`,
- email: user.email,
- nationality: user.nat,
- };
- });
-
- return (
-
- );
-};
-
-export const ExampleFetchComponent = () => {
-
- const { value, loading, error } = useAsync(async (): Promise => {
- // Would use fetch in a real world example
- return exampleUsers.results;
- }, []);
-
- if (loading) {
- return ;
- } else if (error) {
- return ;
- }
-
- return ;
-};
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts
deleted file mode 100644
index 41a43e84f1..0000000000
--- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { ExampleFetchComponent } from './ExampleFetchComponent';
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx
new file mode 100644
index 0000000000..fd1f4620b4
--- /dev/null
+++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx
@@ -0,0 +1,18 @@
+import { screen } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/frontend-test-utils';
+import { TodoList } from './TodoList';
+
+describe('TodoList', () => {
+ it('renders a list of todos', async () => {
+ const todos = [
+ { id: '1', title: 'First task', createdBy: 'user:default/guest', createdAt: '2025-01-01T00:00:00.000Z' },
+ { id: '2', title: 'Second task', createdBy: 'user:default/admin', createdAt: '2025-01-02T00:00:00.000Z' },
+ ];
+
+ await renderInTestApp();
+
+ expect(await screen.findByText('First task')).toBeInTheDocument();
+ expect(await screen.findByText('Second task')).toBeInTheDocument();
+ expect(await screen.findByText('user:default/guest')).toBeInTheDocument();
+ });
+});
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx
new file mode 100644
index 0000000000..63cd37483d
--- /dev/null
+++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,42 @@
+import { Table, useTable, CellText, type ColumnConfig } from '@backstage/ui';
+
+export type TodoItem = {
+ title: string;
+ id: string;
+ createdBy: string;
+ createdAt: string;
+};
+
+const columns: ColumnConfig[] = [
+ {
+ id: 'title',
+ label: 'Title',
+ cell: item => ,
+ },
+ {
+ id: 'createdBy',
+ label: 'Created by',
+ cell: item => ,
+ },
+ {
+ id: 'createdAt',
+ label: 'Created at',
+ cell: item => ,
+ },
+];
+
+export const TodoList = ({ todos }: { todos: TodoItem[] }) => {
+ const { tableProps } = useTable({
+ mode: 'complete',
+ data: todos,
+ paginationOptions: { pageSize: todos.length || 1 },
+ });
+
+ return (
+
+ );
+};
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts
new file mode 100644
index 0000000000..b71610df90
--- /dev/null
+++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts
@@ -0,0 +1,2 @@
+export { TodoList } from './TodoList';
+export type { TodoItem } from './TodoList';
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs
new file mode 100644
index 0000000000..309f03fbfa
--- /dev/null
+++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs
@@ -0,0 +1,43 @@
+import { screen } from '@testing-library/react';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import {
+ registerMswTestHooks,
+ renderInTestApp,
+} from '@backstage/frontend-test-utils';
+import { TodoPage } from './TodoPage';
+
+describe('TodoPage', () => {
+ const server = setupServer();
+ registerMswTestHooks(server);
+
+ it('renders todos from the backend', async () => {
+ server.use(
+ rest.get('*/api/{{pluginId}}/todos', (_, res, ctx) =>
+ res(
+ ctx.json({
+ items: [
+ { id: '1', title: 'Mocked task', createdBy: 'user:default/guest', createdAt: '2025-01-01T00:00:00.000Z' },
+ ],
+ }),
+ ),
+ ),
+ );
+
+ await renderInTestApp();
+
+ expect(await screen.findByText('Mocked task')).toBeInTheDocument();
+ });
+
+ it('falls back to example data when the backend fails', async () => {
+ server.use(
+ rest.get('*/api/{{pluginId}}/todos', (_, res, ctx) =>
+ res(ctx.status(500), ctx.json({ message: 'Internal Server Error' })),
+ ),
+ );
+
+ await renderInTestApp();
+
+ expect(await screen.findByText('Install the backend plugin')).toBeInTheDocument();
+ });
+});
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs
new file mode 100644
index 0000000000..d650cfed08
--- /dev/null
+++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs
@@ -0,0 +1,52 @@
+import { Progress } from '@backstage/core-components';
+import {
+ useApi,
+ fetchApiRef,
+} from '@backstage/frontend-plugin-api';
+import { Header, Container } from '@backstage/ui';
+import useAsync from 'react-use/esm/useAsync';
+import { TodoList } from '../TodoList';
+import type { TodoItem } from '../TodoList';
+
+const exampleTodos: TodoItem[] = [
+ { id: '1', title: 'Install the backend plugin', createdBy: 'user:default/guest', createdAt: new Date().toISOString() },
+ { id: '2', title: 'Connect the frontend to real data', createdBy: 'user:default/guest', createdAt: new Date().toISOString() },
+];
+
+// TEMPLATE NOTE:
+// This is a simple example of fetching data from the backend plugin.
+// You can replace this with your own data fetching logic or use a
+// generated client from an OpenAPI schema.
+function useTodos() {
+ const { fetch } = useApi(fetchApiRef);
+
+ return useAsync(async (): Promise => {
+ const response = await fetch(`plugin://{{pluginId}}/todos`);
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch todos: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+ return data.items;
+ }, [fetch]);
+}
+
+export const TodoPage = () => {
+ const { value: todos, loading, error } = useTodos();
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts
new file mode 100644
index 0000000000..bfae53f4db
--- /dev/null
+++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts
@@ -0,0 +1 @@
+export { TodoPage } from './TodoPage';
diff --git a/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs
index 435f9d4d8e..bb4aa701fd 100644
--- a/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs
+++ b/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs
@@ -10,9 +10,9 @@ export const page = PageBlueprint.make({
path: '/{{pluginId}}',
routeRef: rootRouteRef,
loader: () =>
- import('./components/ExampleComponent').then(m =>
- ,
- ),
+ import('./components/TodoPage').then(m => (
+
+ )),
},
});