next.js: [NEXT-779] next/* - Typescript cannot find module when moduleResolution=nodenext and type=module

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 21.6.0: Thu Sep 29 20:13:56 PDT 2022; root:xnu-8020.240.7~1/RELEASE_ARM64_T6000
Binaries:
  Node: 16.17.0
  npm: 8.15.0
  Yarn: N/A
  pnpm: 7.25.0
Relevant packages:
  next: 13.1.7-canary.18
  eslint-config-next: 13.0.0
  react: 18.2.0
  react-dom: 18.2.0

Which area(s) of Next.js are affected? (leave empty if unsure)

TypeScript

To Reproduce

  • pnpm create next-app (with typescript)
  • add "type": "module" to package.json
  • change moduleResolution to nodenext
  • run npx tsc --noEmit

Describe the Bug

Typescript complain about next/*, for example:

Cannot find module ‘next/head’ or its corresponding type declarations.

I believe this is because there is no exports field in node_modules/next/package.json.

Expected Behavior

Typescript shouldn’t complain.

Context

  • We use "type": "module" as we want repo scripts to be esm rather than cjs.
  • We use "moduleResolution": "nodenext" as we use a setup similar to the monorepo created by npx create-turbo@latest, but we want sources to be under src and not the root folder.

Which browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

No response

NEXT-779

About this issue

  • Original URL
  • State: open
  • Created a year ago
  • Reactions: 43
  • Comments: 16 (6 by maintainers)

Commits related to this issue

Most upvoted comments

Note that nodenext and bundler are fundamentally different module resolution algorithms.

With this context, we’re ready to begin answering your question directly. The biggest, most noticeable difference between --module nodenext and --module esnext is that the former implies --moduleResolution nodenext, a new resolution mode designed for Node’s specific implementation of co-existing ESM and CJS, while the latter does not imply a moduleResolution setting because there is no such corresponding setting in TypeScript right now. Put another way, when you say you’re using --module esnext, you’re allowed to write, and we will emit, the latest and greatest ES module code constructs, but we will not do anything differently with deciding how imports resolve. You’ll likely continue using --moduleResolution node, which was designed for Node’s implementation of CJS. What does this mean for you? If you’re writing ESM for Node, you can probably make some stuff work with --module esnext and --moduleResolution node, but newer Node-specific features like package.json exports won’t work, and it will be extremely easy to shoot yourself in the foot when writing import paths. Paths will be evaluated by tsc under Node’s CJS rules, but then at runtime, Node will evaluate them under its ESM rules since you’re emitting ESM. There are significant differences between these algorithms—notably, the latter requires relative imports to use file extensions instead of dropping the .js, and index files have no special meaning, so you can’t import the index file just by naming the path to the directory.

– https://stackoverflow.com/a/71473145/368691

nodenext needs to be supported by Next.js. Otherwise this causes major maintenance headache for organizations with large monorepos.

Replacing next/some-entrypoint with next/some-entrypoint.js works, but it’d be great to avoid it

I’ve been using these imports with .js suffixes (eg. next/image.js, next/server.js, next/link.js, etc), and it’s worked so far.

However, one big footgun with this which I just ran into (pretty hard to debug) is if you import next/link.js, the feature of TypeScript type checking on Link[href] using typedRoutes: true by @shuding just fails silently:

import Link from 'next/link.js'; // ✅ import works with ESM + Node16 module resolution

<Link href="/unknown"> // ❌ href silently not checked, because `next/link` module in generated type declarations in .next/types/link.d.ts

The offending line in the .next/types/link.d.ts file looks like this:

declare module 'next/link' {

FYI, this went away for me when I changed these settings in my tsconfig.json:

(Read more here: https://www.totaltypescript.com/tsconfig-cheat-sheet)

Submitted a pull request.

Until that is merged/released, here is a local patch that you can use:

diff --git a/package.json b/package.json
index bfa500ed3b8526117c602e6ee9cc2aef847c317e..9025f24c698f89f231fd47da901d9ebc2c2f29cd 100644
--- a/package.json
+++ b/package.json
@@ -99,6 +99,92 @@
     "react-dom": "^18.2.0",
     "sass": "^1.3.0"
   },
+  "exports": {
+    ".": {
+      "import": "./index.js",
+      "types": "./index.d.ts"
+    },
+    "./amp": {
+      "import": "./amp.js",
+      "types": "./amp.d.ts"
+    },
+    "./app": {
+      "import": "./app.js",
+      "types": "./app.d.ts"
+    },
+    "./babel": {
+      "import": "./babel.js",
+      "types": "./babel.d.ts"
+    },
+    "./cache": {
+      "import": "./cache.js",
+      "types": "./cache.d.ts"
+    },
+    "./client": {
+      "import": "./client.js",
+      "types": "./client.d.ts"
+    },
+    "./config": {
+      "import": "./config.js",
+      "types": "./config.d.ts"
+    },
+    "./document": {
+      "import": "./document.js",
+      "types": "./document.d.ts"
+    },
+    "./error": {
+      "import": "./error.js",
+      "types": "./error.d.ts"
+    },
+    "./font": {
+      "import": "./font/index.js",
+      "types": "./font/index.d.ts"
+    },
+    "./font/google": {
+      "import": "./font/google.js",
+      "types": "./font/google.d.ts"
+    },
+    "./font/local": {
+      "import": "./font/local.js",
+      "types": "./font/local.d.ts"
+    },
+    "./head": {
+      "import": "./head.js",
+      "types": "./head.d.ts"
+    },
+    "./headers": {
+      "import": "./headers.js",
+      "types": "./headers.d.ts"
+    },
+    "./image": {
+      "import": "./image.js",
+      "types": "./image.d.ts"
+    },
+    "./link": {
+      "require": "./link.js",
+      "types": "./link.d.ts"
+    },
+    "./navigation": {
+      "import": "./navigation.js",
+      "types": "./navigation.d.ts"
+    },
+    "./router": {
+      "import": "./router.js",
+      "types": "./router.d.ts"
+    },
+    "./script": {
+      "import": "./script.js",
+      "types": "./script.d.ts"
+    },
+    "./server": {
+      "import": "./server.js",
+      "types": "./server.d.ts"
+    },
+    "./web-vitals": {
+      "import": "./web-vitals.js",
+      "types": "./web-vitals.d.ts"
+    }
+  },
   "peerDependenciesMeta": {
     "node-sass": {
       "optional": true

EDIT: sorry, just realized this is similar to this comment

In nodenext we have:

ts: 'Link' cannot be used as a JSX component.

The workaround is:

import Link from "next/link.js";

<Link.default href="" />

However, this does not work (still needs Link.default):

import { default as Link } from "next/link.js";

<Link href="" />

Adding

+ export { Link }
export default Link

to next/link.d.ts works:

import { Link } from "next/link.js";

<Link href="" />

I guess the wildcard export is not working:

export * from './dist/client/link'
import * as Link from 'next/link' // only contains `Link.default`

As an alternative to @lucgagan suggestion above, adding wildcard exports to package.json works for me:

{
  ...
  "exports": {
    ".": "./index.js",
    "./*": "./*.js",
    "./font/*": "./font/*/index.js",
    "./dist/*": "./dist/*/index.js"
  }
}

Node’s support for subpath patterns is documented here.