DEV Community

Cover image for Make @forge/react/jira resolve, and keep forge deploy working
Mihai Perdum
Mihai Perdum

Posted on Originally published at leanzero.net

Make @forge/react/jira resolve, and keep forge deploy working

Key takeaways

  • END STATE: the subpath import type-checks, verified across TypeScript 5.9.2, 6.0.3 and 7.0.2, with a config that also survives forge deploy.
  • Use bundler. On TypeScript 6 and 7 it is a genuine one-line change; on 5 you must set module alongside it, because the two options are interlocked.
  • This is NOT editor-only. @forge/bundler runs ts-loader with no transpileOnly, reading your own tsconfig — so the error fails the build.
  • Three majors, three errors: 5.x TS2307 (module not found), 6.x TS5107 (node10 deprecated), 7.x TS5108 (node10 removed).
  • 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.
Enter fullscreen mode Exit fullscreen mode

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.

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.

  1. Prove the types are installed before believing any error that says they are missing.
  2. Reproduce the failure and identify which of three errors your TypeScript gives.
  3. Inspect the package to see why a present file is unreachable.
  4. Set moduleResolution to bundler, with the module change your version needs.
  5. Verify the whole matrix, and confirm the runtime was never the problem.
  6. Check it against how Forge actually builds, which is the step that matters.

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
└── @forge/react@12.1.3
12.1.3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
      90 node_modules/@forge/react/out/components/jira/index.d.ts
export { CustomFieldEdit } from './custom-field-edit';
//# sourceMappingURL=index.d.ts.map
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode
// tsconfig.node.json  the classic resolver
{
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node",
    "target": "es2022",
    "jsx": "react-jsx",
    "noEmit": true,
    "skipLibCheck": true,
    "strict": false
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
ls: node_modules/@forge/react/jira: No such file or directory
CHANGELOG.md  LICENSE.txt  README.md  __mocks__  out  package.json  tsconfig.tsbuildinfo
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode
{
 ".":        { "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" }
}
Enter fullscreen mode Exit fullscreen mode

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 this is genuinely one line — module can stay as it is:

{ "compilerOptions": { "moduleResolution": "bundler" } }
Enter fullscreen mode Exit fullscreen mode

On TypeScript 5 it is not one line, and any article telling you otherwise has not run it. module and moduleResolution are interlocked, 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
Enter fullscreen mode Exit fullscreen mode

So on 5.x, change both. Here is the complete file — not a fragment, because omitting jsx or noEmit gives you a fresh error and stray .js files in src/:

// tsconfig.bundler.json
{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "target": "es2022",
    "jsx": "react-jsx",
    "noEmit": true,
    "skipLibCheck": true,
    "strict": false
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

How you know it worked: tsc -p tsconfig.bundler.json should print nothing at all, and ls src/ should still show only App.tsx — no emitted .js.

Step 5 — Verify the matrix

Do not take one passing config as proof. Write all four files — each with the module its resolver requires — and run them:

for mr in node node16 nodenext bundler; do
  printf '%-9s ' "$mr"
  ./node_modules/.bin/tsc -p tsconfig.$mr.json >/dev/null 2>&1 && echo COMPILES || echo FAILS
done
Enter fullscreen mode Exit fullscreen mode
node      FAILS
node16    COMPILES
nodenext  COMPILES
bundler   COMPILES
Enter fullscreen mode Exit fullscreen mode

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'))"
Enter fullscreen mode Exit fullscreen mode
…/node_modules/@forge/react/out/components/jira/index.js
Enter fullscreen mode Exit fullscreen mode

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:

grep -rc "ts-loader" node_modules/@forge/bundler
grep -rc "transpileOnly" node_modules/@forge/bundler
Enter fullscreen mode Exit fullscreen mode

How you know it worked: the first count should be non-zero and the second should be exactly 0. Run grep -rc "loader" node_modules/@forge/bundler as a control — if that is also 0 your grep is not reaching the package and the zero means nothing.

Fifteen references to ts-loader, and zero to transpileOnly — with loader itself appearing 103 times, so the search is finding things. ts-loader type-checks by default. Its options block overrides only three settings:

loader: 'ts-loader',
options: {
  compilerOptions: { jsx: 'react', jsxFactory: 'ForgeUI.createElement', sourceMap: true },
  configFile: new TSConfigInteractor(…).getTSConfigPath(),
Enter fullscreen mode Exit fullscreen mode

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.

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.

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 — one line on TypeScript 6 and 7, and paired with module on 5, because 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.

Top comments (0)