Sooner or later, every UI5 developer runs into the same situation: you have an application, and next to it a control library that the app should consume. Maybe there is even a Fiori Launchpad plugin in the mix. Three projects that belong together — but live in three separate repositories.

And then the pain starts. How do you develop the library and the app at the same time? The usual answers are all bad:

  • Publish the library to a registry for every tiny change? Way too slow.
  • npm pack and reinstall the tarball after each edit? Tedious and error-prone.
  • Copy the built library into the app’s webapp folder? Please don’t.
  • resourceroots hacks pointing at some sibling folder? Fragile and nobody on the team understands them six months later.

The good news: none of this is necessary. With pnpm workspaces and the standard UI5 Tooling, all three projects can live in one repository, link against each other live, and start with a single command — including a shared Fiori Launchpad sandbox that ties everything together.

I built a complete, working showcase for this setup:

→ GitHub Repository: ui5-monorepo-showcase

In this post, I’ll walk you through how it works — and the handful of non-obvious settings that make or break the setup.


What We Are Building

The monorepo contains three UI5 TypeScript projects plus one central launchpad sandbox:

PackageTypePortDescription
packages/sample-appUI5 Application8080App com.myorg.myapp, consumes the library via workspace link
packages/sample-libUI5 Library8081Control library com.myorg.mylib with a custom control
packages/sample-pluginFLP Shell Plugin8082Launchpad plugin com.myorg.myplugin, loaded by the shell
flpSandbox.htmlCentral FLP Sandbox8090One launchpad that hosts the app as a tile and loads the plugin
flowchart LR
    subgraph FLP["Central FLP Sandbox :8090"]
        T1[Tile: Sample App]
        T2[Tile: Sample Lib]
        SHELL[Launchpad Shell]
    end

    subgraph WS["pnpm workspace"]
        APP["sample-app :8080"]
        LIB["sample-lib :8081"]
        PLUGIN["sample-plugin :8082"]
    end

    T1 -- iframe --> APP
    T2 -- iframe --> LIB
    SHELL -- bootstrapPlugins --> PLUGIN
    APP -- "workspace:* symlink" --> LIB

The app renders a custom control from the library, the library has its own test page, and the plugin adds a header button to the launchpad shell itself. Everything is TypeScript, everything reloads live.


Why a Monorepo? The Benefits in Plain Words

Before we dive into the configuration, let’s step back for a moment. Why put three projects into one repository at all? Because it removes friction in exactly the places where multi-repo setups hurt every single day:

  • No publishing cycle. In separate repositories, every library change means: bump the version, publish, and reinstall in the app — just to see a button render correctly. In the workspace, the app links directly against the library’s sources. You save a file in sample-lib, reload the browser, and the change is there. That’s it.

  • One command starts everything. New team member? git clone, pnpm install, pnpm start — and the complete landscape is running: app, library, plugin, and the shared launchpad. No wiki page with ten setup steps, no “works on my machine”.

  • One change, one commit. Add a property to a library control and use it in the app? In a multi-repo world that’s two pull requests that must be merged in the right order. Here it’s one atomic commit — the library change and its usage always stay in sync, and reviewers see the full picture in a single diff.

  • The CI catches integration breaks immediately. One pipeline typechecks, lints, and builds all packages on every push. If a library change breaks the app, you find out now — not weeks later when someone finally updates the library version in the app.

  • Type safety across package boundaries. Because the app’s TypeScript sees the library’s sources directly, renaming a control property immediately flags every usage in the app. Refactorings that would be scary across repositories become routine.

In short: everything that belongs together lives together — and the tooling overhead of keeping three separate repositories in sync simply disappears.

Not a silver bullet

A monorepo shines when the projects are developed together and by the same team — exactly the app + library + plugin scenario shown here. Fully independent products with separate release cycles and owners can still be better off in separate repositories.


Where the Packages Come From: easy-ui5

One thing before we start: none of the three packages was written from scratch. They all come straight out of the community easy-ui5 generators — the fastest way to scaffold UI5 TypeScript projects:

PackageGeneratorCommand
packages/sample-appgenerator-ui5-ts-appyo easy-ui5 ts-app
packages/sample-libgenerator-ui5-ts-libraryyo easy-ui5 ts-library
packages/sample-plugingenerator-ui5-ts-flp-pluginyo easy-ui5 ts-flp-plugin
1
2
3
4
npm i -g yo generator-easy-ui5

cd packages
yo easy-ui5 ts-app        # scaffolds a new app into packages/<your-app>

Each generator asks a handful of questions (namespace, framework version, author, …) and produces a complete, standalone projectui5.yaml, TypeScript setup, linting, and test tooling included. And because pnpm-workspace.yaml simply declares packages/*, any project generated into the packages/ folder automatically becomes a workspace member on the next pnpm install. There is no registration step.

That also means the monorepo-specific work in this post is a surprisingly thin layer on top of the generated projects: the workspace:* link between app and library, transpileDependencies, the tsconfig path mappings, unique ports, and the central sandbox. Everything else is generator output.


Step 1: The pnpm Workspace

The foundation is a plain pnpm workspace. The root pnpm-workspace.yaml declares where the packages live:

1
2
packages:
  - packages/*

The app then consumes the library like any other npm dependency — just with the workspace: protocol instead of a version number:

1
2
3
4
5
6
// packages/sample-app/package.json
{
  "dependencies": {
    "com.myorg.mylib": "workspace:*"
  }
}

When you run pnpm install, pnpm does not download anything for this dependency. Instead, it creates a symlink:

1
packages/sample-app/node_modules/com.myorg.mylib → ../../sample-lib

And here is the nice part: the UI5 Tooling follows this symlink automatically. Because the linked package contains a ui5.yaml with type: library, ui5 serve and ui5 build inside the app treat it like any regular UI5 dependency. You can verify it with ui5 tree.

No npm pack, no local registry, no manual resourceroots. Every change in the library is instantly visible in the app.

Don’t forget: the library must still be declared in the app’s manifest.json, otherwise the UI5 runtime won’t load it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// packages/sample-app/webapp/manifest.json
"sap.ui5": {
  "dependencies": {
    "libs": {
      "sap.ui.core": {},
      "sap.m": {},
      "com.myorg.mylib": {}
    }
  }
}

Step 2: Transpiling the Linked TypeScript Library

Both the app and the library are written in TypeScript and use ui5-tooling-transpile. This is where the first trap hides.

By default, the transpile middleware only transpiles the root project. When the app requests /resources/com/myorg/mylib/library.js, the request hits the library’s TypeScript sources — and 404s. Your app boots, but the library is simply gone.

The fix is one line in the app’s ui5.yaml:

1
2
3
4
5
6
7
# packages/sample-app/ui5.yaml
server:
  customMiddleware:
    - name: ui5-tooling-transpile-middleware
      afterMiddleware: compression
      configuration:
        transpileDependencies: true # ← transpile linked TS dependencies too

With transpileDependencies: true, the app’s dev server transpiles the library’s .ts sources on the fly and serves them as JavaScript.

Only relevant for ui5 serve

This option only affects the development server. Production builds are unaffected — there, each package transpiles itself with its own ui5-tooling-transpile-task.


Step 3: TypeScript Across Package Boundaries

Linking the packages at runtime is only half the story. You also want code completion and type safety across packages — for example, importing the typed press event of a library control inside the app:

1
2
3
4
5
6
7
// packages/sample-app/webapp/controller/Main.controller.ts
import type { GreetingCard$PressEvent } from "com/myorg/mylib/GreetingCard";

public onGreetingCardPress(event: GreetingCard$PressEvent): void {
    const card = event.getSource();
    MessageToast.show(`Greeting card pressed by ${card.getName()}`);
}

Two entries in the app’s tsconfig.json make this work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// packages/sample-app/tsconfig.json
{
  "compilerOptions": {
    "paths": {
      // resolve "com/myorg/mylib/*" imports into the workspace sibling
      "com/myorg/mylib/*": ["../sample-lib/src/com/myorg/mylib/*"]
    }
  },
  "include": [
    "./webapp/**/*",
    // pick up the library's generated control interfaces
    "../sample-lib/src/**/*.gen.d.ts"
  ]
}

The *.gen.d.ts files are generated by ui5-tooling-transpile (via generateTsInterfaces: true in the library’s ui5.yaml). They contain the typed accessors (getName(), firePress(), …), the $GreetingCardSettings constructor type, and event types like GreetingCard$PressEvent.

Include the .gen.d.ts files via their real path

TypeScript resolves the pnpm symlink to its real location. An include glob through node_modules/com.myorg.mylib/** will therefore never match — you have to include the files via the actual sibling path (../sample-lib/src/**/*.gen.d.ts). This one cost me some time.

In the XML view, the library control is then used like any other control:

1
2
3
4
5
6
7
<!-- packages/sample-app/webapp/view/Main.view.xml -->
<mvc:View xmlns:mylib="com.myorg.mylib" ...>
    <mylib:GreetingCard
        name="UI5 Developer"
        color="Highlight"
        press=".onGreetingCardPress" />
</mvc:View>

Step 4: One Command to Start Everything

With three dev servers and one static launchpad, nobody wants to open four terminals. The root package.json orchestrates everything:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// package.json (root)
{
  "scripts": {
    "start": "run-p start:projects start:flp",
    "start:projects": "pnpm --recursive --parallel --stream run start",
    "start:flp": "http-server -p 8090 -c-1 --silent -o /flpSandbox.html ."
  },
  "devDependencies": {
    "http-server": "^14.1.1",
    "npm-run-all2": "^7.0.2"
  }
}
  • pnpm --recursive --parallel run start runs the start script of every workspace package at the same time (--stream prefixes each output line with the package folder — very helpful).
  • run-p from npm-run-all2 additionally starts the static http-server that serves the central flpSandbox.html.

Each package pins its own unique port and does not open a browser — only the central launchpad does:

1
2
3
4
5
6
7
8
// packages/sample-app/package.json
"start": "ui5 serve --port 8080"

// packages/sample-lib/package.json
"start": "ui5 serve --port 8081"

// packages/sample-plugin/package.json
"start": "ui5 serve --port 8082 --config ui5-test.yaml"

So the whole developer experience boils down to:

1
2
pnpm install
pnpm start

…and the launchpad opens at http://localhost:8090/flpSandbox.html with the app as a tile, the library test page as a second tile, and the plugin’s header button already in the shell.

Unique ports are not optional

pnpm --parallel starts all servers at once. If two packages default to the same port, one of them dies with EADDRINUSE — so pin a distinct port in every package’s start script.


Step 5: The Central FLP Sandbox

The most interesting part of the showcase is the shared launchpad. flpSandbox.html bootstraps the classic Fiori Launchpad sandbox from the SAPUI5 CDN and wires all three projects together via window["sap-ushell-config"]:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
window["sap-ushell-config"] = {
    defaultRenderer: "fiori2",

    // The FLP plugin: loaded by the SHELL itself, not started via tile
    bootstrapPlugins: {
        FLPPluginAll: {
            component: "com.myorg.myplugin",
            url: "http://localhost:8082/",
        },
    },

    // Apps that appear as tiles on the homepage
    applications: {
        "myapp-display": {
            title: "Sample App",
            applicationType: "URL",
            url: "http://localhost:8080/index.html",
        },
        "mylib-display": {
            title: "Sample Lib",
            applicationType: "URL",
            url: "http://localhost:8081/test-resources/com/myorg/mylib/Example.html",
        },
    },
};

Note the difference between the two mechanisms:

  • Applications appear as tiles and open in an iframe when clicked.
  • The plugin is not a tile. The shell itself fetches its manifest.json and Component.js at startup and runs the component inside the launchpad — in the showcase it registers a header button via the modern Extension service. If you want to dive deeper into plugins, I wrote a dedicated post about Fiori Launchpad plugins with TypeScript.

Cross-Origin Hurdle #1: Iframe Embedding

The tiles use applicationType: "URL", so the app on :8080 is embedded as an iframe from a different origin than the launchpad on :8090. UI5’s clickjacking protection would normally block all interaction — you get the blocked cursor 🚫 and a console error about a missing allowlist. The app therefore allows embedding in its bootstrap:

1
2
3
4
5
6
<!-- packages/sample-app/webapp/index.html -->
<script
    id="sap-ui-bootstrap"
    ...
    data-sap-ui-frame-options="allow"
></script>
Local development only

allow disables clickjacking protection entirely — fine on localhost, not in production. There, use trusted with an explicit allowlist: data-sap-ui-frame-options-config='{"allowlist": ["your-flp-host"]}'


Growing the Workspace: Adding More Projects

The best part of this setup is that it doesn’t stop at three packages. Adding a fourth project — say, a second app — follows the exact same recipe as the first three. Here is the complete checklist.

1. Scaffold it with easy-ui5

Generate the new project directly into the packages/ folder:

1
2
3
4
cd packages
yo easy-ui5 ts-app        # or ts-library / ts-flp-plugin
cd ..
pnpm install

Because the workspace declares packages/*, the new project is picked up automatically — pnpm install links it into the workspace and you are done. No config file to touch.

2. Give it a unique port

The generated start script needs two small edits: pin a free port and make sure it does not open a browser (only the central launchpad does that). In our setup, 80808082 and 8090 are already taken, so the next app gets 8083:

1
2
// packages/my-second-app/package.json
"start": "ui5 serve --port 8083"

That’s all the orchestration needed — the root pnpm start runs the start script of every workspace package, so the new server comes up automatically alongside the others.

3. Register it in the launchpad

To make the new app appear as a tile, add one entry to window["sap-ushell-config"] in flpSandbox.html:

1
2
3
4
5
6
7
8
applications: {
    // ...existing tiles...
    "mysecondapp-display": {
        title: "My Second App",
        applicationType: "URL",
        url: "http://localhost:8083/index.html",
    },
},

4. Allow iframe embedding

Like every app embedded cross-origin in the sandbox, the new app must permit framing in its index.html:

1
<script id="sap-ui-bootstrap" ... data-sap-ui-frame-options="allow"></script>

5. Consuming the shared library? Repeat the linking steps

If the new app should also use com.myorg.mylib, apply the same three settings shown earlier for sample-app:

  • "com.myorg.mylib": "workspace:*" in its package.json (then pnpm install),
  • the library in its manifest.json under sap.ui5/dependencies/libs,
  • transpileDependencies: true in its ui5.yaml, plus the tsconfig.json path mapping and the *.gen.d.ts include for typed imports.

6. Scripts and CI: nothing to do

This is where the monorepo pays off again. The root scripts and the CI pipeline all use pnpm --recursive, so they discover the new package automatically — pnpm start, pnpm -r run build, typecheck, and lint just include it from now on. The only convention to follow: use the same script names as the other packages (start, build, ts-typecheck, lint, test), because that is what the recursive runs invoke. A package that lacks one of these scripts is silently skipped.

Quick checklist for every new package

Scaffold into packages/pnpm install → unique port, no browser-open → tile or bootstrapPlugins entry in flpSandbox.htmlframe-options: allow (apps) → consistent script names. That’s it.


Gotchas & Lessons Learned

A quick checklist of everything that can silently break this setup:

  • transpileDependencies: true in the consuming app — otherwise the linked TypeScript library 404s at runtime.
  • Unique ports per packagepnpm --parallel kills colliding servers with EADDRINUSE.
  • data-sap-ui-frame-options="allow" in every app embedded cross-origin in the sandbox.
  • Pin the CDN version for the classic FLP sandbox (1.120.x) — and always with the full patch version in the URL.
  • pnpm build scripts — postinstall scripts of dependencies (esbuild, browser drivers, …) must be explicitly allowed via allowBuilds in pnpm-workspace.yaml, otherwise pnpm skips them.
  • *.gen.d.ts includes via the real path, not through node_modules — TypeScript resolves the symlink.

Conclusion

A pnpm workspace turns the classic “app + library + plugin” pain into a genuinely pleasant setup: one git clone, one pnpm install, one pnpm start — and you develop all three projects live against each other, with full TypeScript support across package boundaries and a shared launchpad that shows the complete picture.

The best part is how little magic is involved. The workspace: protocol and the symlink-following UI5 Tooling do the heavy lifting; the rest is a handful of well-placed configuration lines (transpileDependencies, paths, CORS, frame options) that you now know about.

The complete, working setup — including CI, tests, and the custom control with generated TypeScript interfaces — is on GitHub. Clone it, run it, and steal whatever you need for your own projects:

Useful links:


Frequently Asked Questions

Does this setup also work with npm or Yarn workspaces instead of pnpm?
Yes. The mechanism the UI5 Tooling relies on is the symlink in node_modules, and npm workspaces and Yarn workspaces create those as well. The workspace:* protocol shown in this post is pnpm/Yarn syntax — with npm workspaces you reference the package with a regular version range instead. I prefer pnpm because it is fast, strict about undeclared dependencies, and its workspace features (--filter, --recursive --parallel) make the orchestration scripts very compact.
Do I have to build the library before I can start the app?
No — that is the whole point of the setup. With transpileDependencies: true in the app’s ui5.yaml, the app’s dev server transpiles the library’s TypeScript sources on the fly. You edit a control in sample-lib, reload the app, and see the change immediately. A build of the library is only needed for production (pnpm -r run build).
What happens in production — are the packages still linked?
No. The workspace link is a development-time convenience only. For production, every package runs its own ui5 build and is deployed as an independent artifact — for example, the app and the library as separate BSP applications on ABAP, or separate HTML5 apps on BTP. The app finds the deployed library at runtime through its manifest.json dependency, exactly as with any standard UI5 library. Alternatively, a self-contained build can bundle the library into the app.
Does this work without TypeScript, too?
Yes — and it gets even simpler. The workspace linking (Step 1) is completely independent of TypeScript. In a plain JavaScript monorepo you can drop the transpile middleware, transpileDependencies, and the tsconfig.json path mappings entirely; the symlinked library is served as-is.
Why not use the UI5 Tooling's own workspace feature (ui5-workspace.yaml) instead?
UI5 CLI v3+ ships a dedicated workspace configuration that maps dependencies to local folders without any npm-level linking. It is a solid alternative if you cannot switch package managers. In a pnpm monorepo, however, it is redundant: the symlinks already exist, one mechanism serves both Node.js resolution and the UI5 Tooling, and there is no extra config file to keep in sync.