RFC-139: Adding "Bring your own bundler" (BYOB) support to the Forge CLI

Project Summary

Atlassian is proposing a way for Forge developers to use tooling of their choice to build Forge apps by delegating the build process to developer-controlled tooling, thereby removing the requirement to use the built-in, non-configurable Webpack used by the Forge CLI for bundling for backend and frontend functions.

Publish: 09 July 2026

Discuss: 17 July 2026

Resolve: 24 July 2026

Problem

The original vision for the Forge CLI was a “batteries included” experience that favoured consistent, simple deployments over configuration and control. As more complex apps are built and hosted on Forge, this approach is no longer ideal. While the JavaScript ecosystem for packaging and bundling is diverse, Forge is presently restricted to a fixed version of TypeScript and Webpack for both backend and frontend code.

Developers have requested more flexibility in how applications are built, including the use of newer tooling, different frontend bundlers, and custom build processes on the backend. We launched a TypeScript Bundler EAP last year, which attempted to address some of these limitations. However, the feedback we received indicated that the approach was not flexible enough for many use cases.

Solving this problem now will allow developers to leverage modern development ecosystems and custom workflows, leading to improved developer productivity and the ability to build more sophisticated Forge applications, with greater ability for app developers to optimise and control build outputs.

Proposed Solution

Atlassian is exploring a solution where Forge supports delegating the build process to developer-controlled tooling. In this model, the app manifest specifies where the already packaged code resides (for example, a backend/dist directory). The Forge CLI then creates functions and UI Kit modules using that code without further transformation.

Scope

Forge functions and UI Kit modules are supported. There’s no change to Custom UI modules (which are already packaged separately), remotes or containers.

Manifest options

Apps may declare manual packaging for backend functions in the app.runtime by setting package and path. The package: manual@2026 declaration tells the CLI that the functions are packaged manually according to the current version of the standard, and path points to the target directory. Omitting package keeps the current automatic packaging.

app:
  runtime:
    package: manual@2026
    path: backend/dist
    name: nodejs24.x

UI Kit resources may declare a prebuilt bundle individually by setting a package property. In this case, path now points to the directory containing the assets instead of a file, as in the following example:

resources:
  - key: broccoli
    package: manual@2026
    path: frontend/broccoli/dist

Manual packaging can be enabled separately for functions and individually for each UI Kit resource (but not per individual backend function).

CLI behaviour

On deploy/build, the Forge CLI will skip compiling and bundling the backend and UI modules that use the manual packaging, and deploy the artefacts prepared by the developer in the specified directories.

It will perform basic linting checks to see whether the contents make sense, but there will be limited linting as we’ll assume the code has already been compiled and bundled.

Tunneling

For backend functions, Forge CLI will watch the contents of the specified directories for changes and automatically reload the functions. It is a responsibility of the developer to recompile the code, or run their packaging tooling in watch mode.

For UI Kit modules, Forge CLI will assume each module will be served by the developer’s local server, similar to the existing implementation for Custom UI modules. It is the responsibility of the developer to start this server and ensure it supports hot module reloading if they want it.

Packaging standard

In order for this to work, pre-packaged code must conform to a defined packaging standard.

Functions

The packaged directory (app.runtime.path) must contain all handlers referenced by function modules in the app manifest.

For every function module with a handler <path>.<export> there must be an exported function <export> in a CommonJS module file <path>.js (or the same path with a different extension loadable by Node.js) inside the output directory.

  • The handler string is <path>.<export> where the two segments separated by the dot are the relative file path (without extension) and the export name.

  • The export must be a function. Both a named export and a declaration like module.exports.<export> would satisfy this.

For example, the following manifest:

modules:
  trigger:
    - key: pickle
      function: main
      events:
        - avi:forge:updated:issue
  function:
    - key: main
      handler: cucumber/two.handler
app:
  runtime:
    package: manual@2026
    path: backend/dist
    name: nodejs24.x

can be satisfied by having backend/dist/cucumber/two.js export a function named handler.

File and directory names starting with double underscore, such as __durian.js, are reserved and cannot be used by handlers.

Dependencies: All production dependencies (not devDependencies), excluding Atlassian front-end dependencies like @forge/react, that are recursively referenced by the project’s package.json, will be resolved according to Node’s rules for finding node_modules and will be available next to the application code.

This means a developer who wants to compile TypeScript themselves doesn’t need to do anything special to include their dependencies, and can point Forge directly at the compiler’s output directory.

UI Kit

A UI Kit resource with manual packaging points via path at an output directory containing the bundle directory. The directory must contain empty HTML files (see name requirements below) and JavaScript files with the UI Kit code and assets required by them.

For a single-entry resource, the HTML must be named index.html. For a multi-entry resource, provide one HTML file per entry, named as declared in the entry map. For example:

resources:
  - key: eggplant
    package: manual@2026
    path: frontend/eggplant/build  # must contain global.html and settings.html
    entry:
      global: global.html
      settings: settings.html
  - key: feijoa
    package: manual@2026
    path: frontend/feijoa/build  # must contain index.html

The HTML files should be empty, but they must reference all JavaScript that is needed for a particular resource in the body, using relative paths. For example, frontend/feijoa/build/index.html could look like:

<!doctype html>
<html>
  <head></head>
  <body>
    <script type="module" src="./index.js"></script>
  </body>
</html>

The JavaScript files must:

  • Not contain any JSX syntax - it must be transformed already using React.createElement pragma (e.g. Vite @vitejs/plugin-react, Rollup @rollup/plugin-babel, esbuild --jsx-factory=React.createElement).

  • Define the application component according to UI Kit documentation in exactly one JavaScript file per resource.

  • Use asset paths relative to the resource root. This is non-default for some tools — e.g. Webpack and Vite default to an absolute / base and need base: './'.

File and directory names starting with double underscore, such as __garlic.js, are reserved and cannot appear in the output directory.

Compatibility

  • The packaging standard itself would be versioned (eg. manual@2026) and over time may require changes. These will comply with the Forge deprecation policy, and we would strive to minimise un-necessary changes.
  • The existing built-in Forge bundler would not be removed and remain available for app developers who have no preference on bundling/packaging tooling. On-going enhancements to the built-in bundler (such as upgrading the Typescript version) would not be paused or discarded as a result of adding the custom packaging capability.
  • While the TypeScript Bundler EAP is closed, the underlying implementation will be removed once the manual packaging feature is implemented.

Asks

While we would appreciate any reactions you have to this RFC (even sharing that this would be valuable for you is helpful), we’re especially interested in learning more about:

  • If you have or are considering a custom packaging solution for your Forge app, does the proposed solution make it easier for you? What custom packaging solution do you want to use?

  • What are your concerns with the proposed tunnel workflows, specifically regarding the responsibility of recompiling code or running packaging tooling in watch mode?

  • Are there specific limitations in the proposed packaging standard (such as the CommonJS requirement for functions or the HTML structure for UI Kit) that would get in your way or prevent you from using it?

  • Are there other ways this change may impact your development workflow that we haven’t anticipated?

Hi @HeyJoe ,

Thank you for this elaborate RFC.

For what it is worth, we are currently already using our own bundler for both Forge functions as well as our custom UI and forge remotes.

The Forge functions are bundled using Rollup. The end result of our build process is a single dist folder with the following structure:

  • dist/
    • public/
      • resourceA
      • resourceB
      • resourceC
    • src/
      • functions/
        • index.js
    • manifest.yml
    • package.json

We deploy our app using the Forge CLI from the dist/ folder, so Forge CLI picks up the manifest.yml and the (stripped) package.json.

In our manifest.yml, we refer to the resources and functions like this:

...
resources:
  - key: resourceA
    path: ./public/resourceA
  - key: resourceB
    path: ./public/resourceB
...
modules:
  ...
  function:
    - key: functionA
      handler: functions/index.functionA
    - key: functionB
      handler: functions/index.functionB
  ...

Although it requires an extra step for us to move everything into the dist/ folder, and accept the PITA of the requirement for Forge functions to reside in a src/ folder, in essence this set-up already works fine for us.

It allows us to use our own Typescript version and bundlers. In addition, it works extra well as it is basically the same setup we have for our Forge Remote deployments to Firebase (except for the moving to dist/ folder path).

Basically, the only real requirement for us would be to be able to just use (relative) paths to the output from the manifest file (perhaps even for package.json) instead of hard-coded requirements on folder structure (like the src/ folder).

I hope this helps!

Cheers,

Remie

I think this tackles problem from the wrong side. Just expose single http endpoint where we upload a zip file with manifest, and js/html/css/media files. A documented deployment package. On that developers or you could build solutions.

So, why the default of CommonJS still? And can we assume then that if anything that node can accept, we can feed it .mjs.

For context our codebase is erasableSyntaxOnly, stripped of type annotations with import extensions rewritten, so it’s pure ES modules, which node has no problems handling.

Can we just supply this, without the webpack mangling, with this solution? (with .mjs extension)

In fact, node can even do it’s own type-stripping, can you just enable that?

We are also doing bundles ourselves, and would also love if we could move away from the hard-coded src/ path.

Some points mentioned here are valid (e.g. CJS vs ESM), but I wouldn’t consider them showstoppers: it’s just a matter of changing the config in our bundler – which we’re already using to pre-bundle our Typescript assets.

Asking Atlassian to maintain forever a Webpack config that has to accommodate for all the weird edge cases in the Node ecosystem would be untenable, so this is definitely a step in the right direction even if it introduces some extra burden on new app developers.

Long story short: we’d like to start using this yesterday, if any improvement comes up it will be a welcome change but having this feature would make everyone’s life way easier.

As someone who is already doing a precompile with TS and Webpack before feeding the output to the Forge CLI, I like this idea a lot.

A few thoughts:

  • It sounds like this will be handled automatically, but the chosen solution should carry through source maps correctly. This is a pain point with the current pre-build solution, because I have not found a way to get readable stack traces out of the back end.

  • While you are redoing the bundler logic, can you allow Forge CLI to accept an argument that provides the explicit location of manifest.yml, rather than requiring it to be in the current directory? The current pre-build system requires us to copy the manifest.yml to the target build directory, and it also requires our build scripts to change the current directory to match the root of the fake Forge project before running any Forge command. (We also preprocess the manifest to do some string replacements, but that is another subject.)

It would be much simpler if we could run the CLI from anywhere but provide an argument like --manifest ./path/to/manifest.yml. Perhaps a --project-root ./path/to/resources would also be helpful to specify the relative root for all of the resources referenced in the manifest, if not the current directory.

Hey Joe :slight_smile:

We really appreciate this! Note that I can speak for the UI Kit portions of the proposal since we don’t use it, but other than that caveat:

If you have or are considering a custom packaging solution for your Forge app, does the proposed solution make it easier for you? What custom packaging solution do you want to use?

We’re using our own esbuild-based bundler for both resources and functions and the slow Webpack recompile during tunneling has been a test of our patience at times :slight_smile:

What are your concerns with the proposed tunnel workflows, specifically regarding the responsibility of recompiling code or running packaging tooling in watch mode?

We already do that. I’d expect wait times during development with tunnel to go down during some phases since some changes took up to a minute to reload at times.

Are there specific limitations in the proposed packaging standard (such as the CommonJS requirement for functions or the HTML structure for UI Kit) that would get in your way or prevent you from using it?

Since output format is really just a compiler/bundler setting, I have no concerns here.

Are there other ways this change may impact your development workflow that we haven’t anticipated?

I can’t think of any.


This RFC is exactly what I had been hoping for, thank you!

Cheers,
Tobi from resolution

Thank you all for the comments and suggestions!

We want to bring this to developers faster, and there are some internal reasons for this. This feedback is helpful for us to prioritise ESM support.

If you use manual packaging, Forge will stop caring about the src directory. As your structure goes, you’ll be able to set path: src/functions and handler: index.functionA, but then you’re free to rename and reorganise.

I agree and we’ll ensure source maps work. If it wasn’t clear, the whole directory you specify will be uploaded to the Forge function, so the source maps will make it if they are there.

Good idea, but we’ll need to work out how do the relative paths in the manifest work when the manifest itself is somewhere else.

Relative paths should always be relative to the directory in which the file resides. If I use relative paths in my manifest.yml, my base assumption will be that the directory in which the manifest.yml resides is the starting point.

This works the same in many other CLI, for instance if I use relative paths in docker-compose.yml and execute docker-compose -f /path/to/docker-compose.yml from /tmp, the relative paths will be resolved based on the /path/to/docker-compose.yml directory, not from /tmp

Sure, but you can also achieve this without requiring me to add two more properties to the manifest.

Yes, a step in the right direction =)

Does this solution make it easier for you?

Yes: It would avoid having forge re-bundle already bundled resources.

Tunnel and recompiling code:

Probably works fine. Our build has a watch mode already. We already integrated it with the current forge tunnel,
where our bundled things are copied into the Forge environment for the Forge tunnel. So we can adapt that.
With the plus of avoiding the Forge bundler :wink:

Limitations with the packaging standard:

We would prefer standard ES modules over CommonJS. But we can work with CommonJS modules as well.

Other Concerns:
One thing unclear to me is the dependency resolution.

Will it pick up things out of the existing node_modules directory in the local system?
Or will it run something like npm ci independently.

I’m asking because we’re using an alternative package manager (eg. yarn / pnpm).
We want get the dependencies it resolved from it’s lock-files/extra config, and not what npm ci would resolve.

Out-of scope, but related:

  • I would love to have a forge build --to-local-directory/tar: A build where the final result is local. So we can inspect / debug excatly what is shipped. And the a equivalent forge deploy --from-local-directory/tar.

  • In the end, long term, I’d love to have a documented API all the way, so that I do not need the Forge CLI at all

Have you tried FORGE_INSPECT_ARCHIVE=bundle forge deploy?

No…not aware of this option. Is this even documented anywhere or some env variable you’ve discovered to exist?

It seems like this could be done by assuming that all paths are relative to the manifest location, but my previous suggestion of an optional argument of --project-root could also allow users to specify a directory to override that assumption.

I don’t know about documentation, but in brief, it is an environment variable that can be set to a path into which you want a copy of the deployed bundle stored. eg. FORGE_INSPECT_ARCHIVE=../private/bundle-output-directory forge deploy

This is where I’d like your input: what would you like to happen? Ultimately there is no yarn and pnpm on the execution environment, just a plain Node process. It knows how to do node_modules but probably not a lot beyond that.

We can copy the required modules out of node_modules but as different package managers install their own hooks (like Yarn PnP), it would require non-trivial decisions to upload the required dependencies.

So far, I see approximately these options:

  • Do nothing, require the developer to supply node_modules (or another module structure) inside the target directory.
  • Recognise node_modules like the vanilla Node would.

If you use a custom loader dependency system, how would you package your dependencies to run without your package manager?

I’m a bit confused here, because this seems to be a solved problem? Both Google Cloud Platform and AWS have solutions for this. Why reinvent the wheel?

For instance, for GCP, the CLI will simply upload the files in the folder, run npm install or yarn install (depending on whether there is a yarn.lock file), put in a container and have NodeJS run the file from "main" in the package.json.

Static hosting (firebase) is uploaded from the path specified in a firebase.json file, which is similar to the manifest.yml.

Why make it more complicated than this? This is not a difficult problem to solve.

Yes, I would go that route as well. Copy whatever is in node_modules for the function. Never run any npm install/ci command behind my back.

The problem with node_modules isn’t really the modules themselves: we can sort-of easily instruct the bundler to tree-shake everything I need and put it into a something.js file that gets require’d by my code.

The issue we’ve seen with the current bundler and node_modules is when we have .node or .wasm files in our node_modules and Webpack doesn’t recognize them.

To entirely avoid Atlassian having to care about what we’re doing, I’d suggest that we as the vendors would put whatever needs to be uploaded into the target directory, and @forge/cli simply takes that directory and uploads it as-is