Make @forge/react/jira resolve, and keep forge deploy working
Key takeaways
- END STATE: the subpath import type-checks on TypeScript 5.9.2, 6.0.3 and 7.0.2, and the same tsconfig.json bundles through the Forge CLI's own bundler (CLI 14.1.0 and 14.2.0, re-run 23 October 2026).
- Use
bundlerand setmoduletoesnext(orpreserve) with it, whatever TypeScript your editor runs. The Forge CLI compiles with its own TypeScript 5.9.2, somoduleResolutionalone passestsc7 and failsforge deploywith TS5095. - This is NOT editor-only. @forge/bundler runs ts-loader with no transpileOnly, reading your own tsconfig.json, so the error fails the build. Keep
noEmitout of that file, or the bundle fails with "TypeScript emitted no output". - Three majors, three errors in your editor: 5.x TS2307 (module not found), 6.x TS5107 (node10 deprecated), 7.x TS5108 (node10 removed). The deploy shows the 5.x one, inside "TypeScript errors in the app caused the bundling to fail".
- If your package.json has "type": "module", node16 and nodenext both break on Forge's CJS packages. Only bundler survives.
You add a Jira custom field to a Forge app, import the component the docs tell you to import, and TypeScript says the module does not exist:
error TS2307: Cannot find module '@forge/react/jira' or its corresponding type declarations.
The natural reading is that something failed to install. It did not — the declaration file is on disk the whole time. What is wrong is the resolver.
Two things make this worth a full write-up rather than a one-line answer. On a Forge app it is not a cosmetic editor complaint: the bundler type-checks, so this fails your build. And the error you actually see depends on which TypeScript major you are running, across three that are all current.
Correction, 23 October 2026: the first version of step 4 said that on TypeScript 6 or 7 the fix was one line and module could stay as it was. Your own tsc agrees with that, and forge deploy doesn't, because the Forge CLI type-checks with the TypeScript 5.9.2 it ships and not the one in your project. I ran the CLI's bundler on a UI Kit entry with that one-line config and it failed with TS5095. The step 4 file also had noEmit on, which makes the same bundle fail with TypeScript emitted no output, and it was called tsconfig.bundler.json, a file forge deploy never reads. Step 4 now sets module whatever TypeScript you run, and the file is tsconfig.json without noEmit. Step 6 greps the CLI's own bundler, and step 7 is the run. Re-checked the same day on @forge/react 12.3.1: the exports map and the missing jira folder are unchanged.
Prerequisites
Node 18+ and npm. Verified on Node v24.15.0.
@forge/react— checked against 12.1.3, published 31 August 2026.Nothing needs deploying, and no Atlassian site is touched. You do need network access for the installs.
TypeScript is installed per step. Results below cover 5.9.2, 6.0.3 and 7.0.2.
Steps 6 and 7 need the Forge CLI installed globally (
npm install -g @forge/cli). Re-run on CLI 14.1.0 and 14.2.0, with@forge/react12.3.1 in the project, on 23 October 2026.
- Prove the types are installed before believing any error that says they are missing.
- Reproduce the failure and identify which of three errors your TypeScript gives.
- Inspect the package to see why a present file is unreachable.
- Set moduleResolution to bundler, with the module change your version needs.
- Verify the whole matrix, and confirm the runtime was never the problem.
- Check it against how Forge actually builds, which is the step that matters.
- Run the Forge CLI's own bundler, because forge deploy compiles with the CLI's TypeScript and not yours.
Step 1 — Prove the types are installed
This step exists because I skipped it and burned a run. My first attempt reported that the package had no jira directory — true, and meaningless, because the install had silently failed and there was no package at all.
mkdir forge-react-probe && cd forge-react-probe
npm init -y >/dev/null
npm install @forge/react@12.1.3 typescript@5.9.2
How you know it worked. Both commands should print 12.1.3. Note the first one carefully:
npm ls @forge/react
node -p "require('./node_modules/@forge/react/package.json').version"
└── @forge/react@12.1.3
12.1.3
The relative path in that second command is not incidental. The obvious version — require('@forge/react/package.json') — throws, because the package's exports map does not publish ./package.json:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package.json' is not defined
by "exports" in .../node_modules/@forge/react/package.json
That is the same mechanism this whole article is about, biting a diagnostic command. I had the bare-specifier version in an earlier draft of this piece, and it took a review to catch that I had run one command and written down another.
Now look at the declaration file itself:
wc -c node_modules/@forge/react/out/components/jira/index.d.ts
cat node_modules/@forge/react/out/components/jira/index.d.ts
90 node_modules/@forge/react/out/components/jira/index.d.ts
export { CustomFieldEdit } from './custom-field-edit';
//# sourceMappingURL=index.d.ts.map
Ninety bytes, two lines, and it exports exactly the symbol you want. Anything that now says "cannot find module or its corresponding type declarations" is talking about resolution, not existence.
Step 2 — Reproduce, and identify your error
// src/App.tsx
import { CustomFieldEdit } from "@forge/react/jira";
export const App = () => <CustomFieldEdit onSubmit={() => {}}>{null}</CustomFieldEdit>;
// tsconfig.node.json — the classic resolver
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2022",
"jsx": "react-jsx",
"noEmit": true,
"skipLibCheck": true,
"strict": false
},
"include": ["src"]
}
How you know it worked — it fails, and you should see one of exactly three errors, which identifies your version:
5.9.2 TS2307 Cannot find module '@forge/react/jira' or its corresponding type
declarations. There are types at '…/out/components/jira/index.d.ts',
but this result could not be resolved under your current
'moduleResolution' setting. Consider updating to 'node16',
'nodenext', or 'bundler'.
6.0.3 TS5107 Option 'moduleResolution=node10' is deprecated and will stop
functioning in TypeScript 7.0. Specify compilerOption
'"ignoreDeprecations": "6.0"' to silence this error.
7.0.2 TS5108 Option 'moduleResolution=node10' has been removed. Please remove it
from your configuration.
Only the 5.x message describes the actual problem, and it names the fix in its second sentence — which is the part that gets cut when the error is pasted into a forum post. On 6 and 7, node is an alias for node10, which 6 deprecated and 7 removed, so the compiler complains about your config before it ever gets to the import. Silence the 6.x deprecation with ignoreDeprecations and you land back on TS2307.
Step 3 — Inspect the package
Two commands explain everything.
ls -d node_modules/@forge/react/jira
ls node_modules/@forge/react
ls: node_modules/@forge/react/jira: No such file or directory
CHANGELOG.md LICENSE.txt README.md __mocks__ out package.json tsconfig.tsbuildinfo
There is no jira folder. The subpath exists only as an entry in the exports map:
node -p "JSON.stringify(require('./node_modules/@forge/react/package.json').exports, null, 1)"
{
".": { "types": "./out/index.d.ts", "default": "./out/index.js" },
"./jira": { "types": "./out/components/jira/index.d.ts", "default": "./out/components/jira/index.js" },
"./global": { "types": "./out/components/global/index.d.ts", "default": "./out/components/global/index.js" },
"./router": { "types": "./out/router/index.d.ts", "default": "./out/router/index.js" }
}
How you know it worked: the first command should print No such file or directory, and the second should print a JSON object containing a "./jira" key. Seeing both together is the diagnosis — the subpath is declared and the folder is not there.
@forge/react/jira is a declared subpath mapping onto a file several directories away. The classic node resolver predates exports maps and ignores them, so it looks for node_modules/@forge/react/jira, finds nothing, and reports a missing module. Three subpaths are published this way, so ./global and ./router fail identically.
Step 4 — Set moduleResolution to bundler
Use bundler. It is what Forge's build actually is, it is the only value that stays safe in the case in step 6, and it is what Atlassian's own Custom UI frame ships.
On TypeScript 6 or 7, your own tsc accepts a one-line change and leaves module alone:
{ "compilerOptions": { "moduleResolution": "bundler" } }
Don't ship that. forge deploy type-checks with the TypeScript inside the Forge CLI, which is 5.9.2 (step 7 prints it), and on TypeScript 5 it takes two lines. module and moduleResolution are interlocked there, and changing only one gives you a different error:
module stays commonjs, mr=node16 -> TS5110: Option 'module' must be set to 'Node16'…
module stays commonjs, mr=nodenext -> TS5110: Option 'module' must be set to 'NodeNext'…
module stays commonjs, mr=bundler -> TS5095: Option 'bundler' can only be used when
'module' is set to 'preserve' or to 'es2015' or later
So change both, on every TypeScript version, because the deploy runs 5.x no matter what your editor has. Put it in tsconfig.json, the file forge deploy reads, and leave noEmit out: ts-loader needs TypeScript to emit, and with noEmit on the bundle fails with TypeScript emitted no output. When you run tsc yourself, pass --noEmit on the command line instead. Omitting jsx still gives you a fresh error:
// tsconfig.json
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": false
},
"include": ["src"]
}
How you know it worked: tsc -p tsconfig.json --noEmit should print nothing at all, and ls src/ should still show only App.tsx, with no emitted .js.
Step 5 — Verify the matrix
Do not take one passing config as proof. Copy your step 4 file to tsconfig.bundler.json and keep step 2's tsconfig.node.json. Then add tsconfig.node16.json and tsconfig.nodenext.json, each with the module its resolver requires, and run all four:
for mr in node node16 nodenext bundler; do
printf '%-9s ' "$mr"
./node_modules/.bin/tsc -p tsconfig.$mr.json --noEmit >/dev/null 2>&1 && echo COMPILES || echo FAILS
done
node FAILS
node16 COMPILES
nodenext COMPILES
bundler COMPILES
How you know it worked: you should see exactly one FAILS — the node row — and three COMPILES. If more than one fails, your tsconfigs are missing the matching module (see step 4); if none fails, you are not running the classic resolver at all.
One failure and three successes, on 5.9.2 and on 7.0.2 alike. That the failing case is the only failing case is what makes this a configuration diagnosis rather than a guess.
Then confirm what was never broken:
node -e "console.log(require.resolve('@forge/react/jira'))"
…/node_modules/@forge/react/out/components/jira/index.js
Node's runtime resolver has understood exports maps for years, so the JavaScript itself was always going to load. That explains the workaround you will find in forum threads — moving the import into a plain .js file makes the error vanish because you have stopped type-checking it, not because anything was fixed.
Step 6 — Check it against how Forge actually builds
This is the step that turns the advice from an editor preference into a requirement, and it is the one I nearly left out.
@forge/bundler compiles TypeScript with ts-loader, and it does not set transpileOnly:
B=$(npm root -g)/@forge/cli/node_modules/@forge/bundler
grep -r "ts-loader" $B | wc -l
grep -r "transpileOnly" $B | wc -l
How you know it worked: the first number should be non-zero and the second exactly 0. Run grep -r "loader" $B | wc -l as a control. If that is also 0, your grep is not reaching the package and the zero means nothing. $B is the bundler inside the global Forge CLI, the copy forge deploy runs (step 7).
The September run counted 15 references to ts-loader, zero to transpileOnly and 103 to loader. On 23 October, in the bundler inside Forge CLI 14.1.0 and 14.2.0 (@forge/bundler 7.2.3 and 7.3.0), the three commands print 15, 0 and 37, so the search is finding things and the zero held. ts-loader type-checks by default. For a Forge function, the options block overrides only three settings:
loader: 'ts-loader',
options: {
compilerOptions: { jsx: 'react', jsxFactory: 'ForgeUI.createElement', sourceMap: true },
configFile: new TSConfigInteractor(…).getTSConfigPath(),
module and moduleResolution are not among them — they come from your tsconfig.json, which getTSConfigPath() locates in your app directory. So an unresolved subpath import is not a red squiggle you can ignore. It is a type error inside your build.
That block is the config for functions. CustomFieldEdit is a UI Kit component, and UI Kit resources go through a second config (out/config/nativeui.js) that lists ts-loader with no options at all, so ts-loader picks up the nearest tsconfig.json above your entry file by itself. In my run it read the one in the project root. So the UI Kit build also type-checks with your module and your moduleResolution.
And there is one combination to avoid. If your package.json declares "type": "module", node16 and nodenext model Node's native ESM-to-CommonJS interop — and @forge/react and @forge/resolver are transpiled CommonJS with a default export. Under those two settings you get errors like Property 'render' does not exist and This expression is not constructable, and esModuleInterop does not rescue you. bundler is immune, because it models bundler interop, which is what Forge actually runs. That is the real reason to lead with bundler rather than treat the three as equivalent.
Step 7 — Reproduce "TypeScript errors in the app caused the bundling to fail" with the Forge CLI's compiler
My first version stopped at tsc. The Forge CLI carries its own node_modules, and ts-loader loads typescript from there. Ask the globally installed CLI which versions it has:
CLI=$(npm root -g)/@forge/cli/node_modules
node -p "require('$CLI/typescript/package.json').version"
node -p "require('$CLI/@forge/bundler/package.json').version"
5.9.2
7.3.0
That's Forge CLI 14.2.0. CLI 14.1.0 prints 7.2.3 on the second line, with the same TypeScript. Installing the CLI inside the project doesn't change it: 14.2.0 keeps its own ts-loader and TypeScript 5.9.2 under node_modules/@forge/cli/node_modules, and with 7.0.2 in the project it gave the same TS5095. Your project's TypeScript only gets in if you call a copy of @forge/bundler you installed yourself, and forge deploy doesn't do that.
Now build a UI Kit entry the way forge deploy does. The CLI hands UI Kit resources to getNativeUiBuildConfig and passes the webpack result to handleWebpackCompilationResult, and the error message comes from that second function. Both are exported, so a short script can call them from the folder that holds your tsconfig.json:
// src/frontend/index.tsx
import ForgeReconciler from '@forge/react';
import { CustomFieldEdit } from '@forge/react/jira';
const App = () => <CustomFieldEdit onSubmit={() => {}}>{null}</CustomFieldEdit>;
ForgeReconciler.render(<App />);
// build.js: bundle one UI Kit entry with the Forge CLI's own bundler
const path = require('path');
const { execSync } = require('child_process');
const CLI = execSync('npm root -g').toString().trim() + '/@forge/cli/node_modules';
const { getNativeUiBuildConfig, getCompiler, handleWebpackCompilationResult } = require(CLI + '/@forge/bundler');
const config = getNativeUiBuildConfig([{ name: 'main', path: path.resolve('src/frontend/index.tsx') }], undefined, false);
getCompiler(config).run((err, stats) => {
try { handleWebpackCompilationResult({ warn: () => {} }, err, stats); console.log('BUNDLED OK'); }
catch (e) { console.log(e.message); process.exitCode = 1; }
});
Run node build.js once per tsconfig. I used the step 4 file, changed only module and moduleResolution, and had TypeScript 7.0.2 installed in the project. This is CLI 14.1.0; on 14.2.0 the TS5095 row and the esnext row came out the same. If step 2's src/App.tsx is still in src/, the node row lists the same TS2307 for App.tsx(1,33) first, because ts-loader checks the files your tsconfig includes and src/ holds both:
module=commonjs moduleResolution=node TypeScript errors in the app caused the bundling to fail.
Fix the errors listed below before rerunning the command.
[tsl] ERROR in …/src/frontend/index.tsx(3,33)
TS2307: Cannot find module '@forge/react/jira' or its
corresponding type declarations.
module=commonjs moduleResolution=bundler TypeScript errors in the app caused the bundling to fail. …
TS5095: Option 'bundler' can only be used when 'module'
is set to 'preserve' or to 'es2015' or later.
module=esnext moduleResolution=bundler BUNDLED OK
module=preserve moduleResolution=bundler BUNDLED OK
module=node16 moduleResolution=node16 BUNDLED OK
module=nodenext moduleResolution=nodenext BUNDLED OK
How you know it worked: the commonjs plus bundler row fails with TS5095, while tsc --noEmit 7.0.2 in the same folder accepts that exact tsconfig with exit code 0. As a control, change the import to @forge/react/jiraX under the working config: the build must still fail, and it does, this time as Bundling failed: with webpack's "./jiraX" is not exported next to a TS2307. If it bundles, the type-check isn't running.
When this message turns up in a real forge deploy, read the TS code after [tsl] ERROR and look it up in step 2 (TS2307) or step 4 (TS5095, TS5110). The wrapper means TypeScript was the only thing that failed. If webpack fails too, as in the control, the same codes come under Bundling failed:.
What this tells you about the next one
"Cannot find module X or its corresponding type declarations" has two very different causes wearing one message: the thing is absent, or the thing is present and your resolver is not allowed to look there. Those need opposite responses, and the cheap discriminator is to ls the file the package claims to publish. If it is there, stop reinstalling.
The same shape appears in the @forge/bridge import that kills your resolver — an import statement that is correct, and an environment that decides what it means.
@forge/react publishes ./jira, ./global and ./router only through its exports map, with no matching folders on disk, so TypeScript's classic resolver reports a missing module while the declaration sits installed nearby. Set moduleResolution to bundler and module to esnext together, on any TypeScript version, because the Forge CLI compiles with 5.9.2 and on 5 the two options are interlocked. It is also the only value that survives an ESM Forge app.
Treat this as a build problem rather than an editor one: Forge bundles through ts-loader with type-checking on and reads your own tsconfig, so the error you are ignoring in your editor is the error that will fail your deploy.
And check that a file is really installed before believing an error that says it is missing. I burned a run on that, and then wrote a verification command into an early draft of this article that could not itself run — blocked by the very exports map the article is about.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.
Top comments (0)