Why the Repository Structure Is Your Customisation Capability
In one line
What npx @backstage/create-app gives you is not a copy of Backstage but a monorepo that you own. Customizing is not flipping one line of configuration; it is changing the code of that repository, building it, and deploying it, so knowing what is where is itself the ability to customize.
Why this was needed
The questions a team that adopts a portal first runs into are usually these.
- I want to show on-call information on the service page; which file should I change?
- To add an internal-only feature, where do I create the new package?
- I attached a plugin, but nothing shows on screen. What did I miss?
- How do I upgrade? Won't it overwrite what I changed?
These questions have something in common. All of them have answers if you know the repository structure, and none if you do not. Backstage is not a "product you turn on and off with configuration," so there is no answer you can find on screen.
How it works
The repository is one workspace
The tree that create-app makes is a monorepo that uses yarn workspaces.
package.json 루트. private: true, workspaces 에 packages/* 와 plugins/*
packages/app/ 프런트엔드 애플리케이션 (React)
packages/backend/ 백엔드 애플리케이션 (Node.js)
plugins/<이름>/ 사내에서 만든 플러그인
app-config.yaml 설정
The root is private: true because this package itself is never published. And you need to put plugins/* in workspaces so that an app can pull in an internal plugin by a name like @internal/plugin-oncall without publishing it to an npm registry.
backstage.role in package.json is the key
Every package's package.json has a backstage.role. backstage-cli looks at this one value to decide how to build and test that package.
| role | What it is |
|---|---|
frontend |
The frontend app, of which packages/app is the only one |
backend |
The backend app, of which packages/backend is the only one |
frontend-plugin |
A plugin that runs in the browser |
backend-plugin |
A plugin that runs on the server |
common-library · node-library · web-library |
Shared code that is not a plugin |
If you write the role wrong, the build succeeds but the output comes out strange. That is also why a backend package's main must point to the build output (dist/...).
The new backend system only registers
packages/backend/src/index.ts is the whole backend.
const backend = createBackend();
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-catalog-backend-module-github'));
backend.start();
The old backend required building a router by hand for each plugin and passing the logger, configuration, and database yourself. In the new backend system, you only register and the required things are injected. Here the difference between a plugin and a module comes up on the exam.
- A plugin provides an entire feature. Like the catalog, the scaffolder, or TechDocs.
- A module is a piece that plugs into an existing plugin's extension point.
plugin-catalog-backend-module-githubplugs GitHub discovery into the catalog plugin.
If the name contains -module-, it does not work alone, and the plugin it pairs with must be registered together.
A frontend plugin starts from three files
src/routes.ts createRouteRef 로 라우트 참조를 만들어 내보낸다
src/plugin.ts createPlugin 으로 플러그인을 만들고, 페이지를 확장으로 제공한다
src/index.ts 바깥에 공개할 것만 다시 내보낸다
There is a reason to put the route reference in a separate file. If a page component references the plugin and the plugin references the component again, a circular import arises. If you keep the file that holds only the reference separate, that loop is broken.
And a page that has a route is made with createRoutableExtension, and you hook the route reference to mountPoint. The component is passed as a lazy import, so the code is not downloaded until that plugin is actually opened.
Keeping only index.ts as the public entry point is discipline too. Once the app starts importing a plugin's internal files directly, you can never change the plugin's internal structure again.
To attach a tab to an entity page, you change two places
<EntitySwitch>
<EntitySwitch.Case if={isKind('component')}>
<EntityLayout>
<EntityLayout.Route path="/oncall" title="On-call">
<OncallPage />
</EntityLayout.Route>
You show different pages per kind with EntitySwitch and isKind, and attach a tab with EntityLayout.Route. A common mistake comes from here. If you attach only the tab and do not register the route in App.tsx, the tab shows but a blank screen appears when you click it. There is no error on screen, so the cause is hard to find.
Development workflow
yarn install --immutable means "do not modify the lock file." If you drop this flag in CI, dependencies quietly go up, and a commit that passed yesterday builds differently today. Next come type checking, building, and testing in that order. This order matters because a single type error hides all the other output.
What it looks like in the field
This repository has the same boundary. logos.js, i18n.js, and backend/app/grader_paths.py are generated files, so if you edit them by hand they vanish at the next generation. What you should fix is the generator side. A Backstage app has a boundary of exactly the same nature. packages/app/src is my code, and the plugins inside node_modules are other people's code. If you blur the boundary and start editing other people's code directly, upgrading becomes impossible.
One more. On a day when several people were editing this repository in parallel, there was a time when a single TypeScript syntax error made the checker finish without looking at the rest at all. "The checker found nothing" was actually "nothing was checked." A portal repository has the same thing happen once it grows. This is why you put type checking before the build in CI.
What you will do in the next lab
In /root/cba-app/, you set up the repository structure that create-app would produce. You create the workspace root, two apps, and two internal plugins, and give each package a role. Then you register a plugin and a module the new backend system way, write the three files of a frontend plugin, and attach a tab to an entity page. Finally you extract a package inventory and set up a CI gate. This is an environment where node dependencies cannot be downloaded, so you do not build; you work only with the structure and the wiring.