node.jstypescriptnpmnext.jsmonorepo

Typescript composite project with NextJS


I have reorganized my project in NodeJS/Typescript using workspaces and project references. One of the workspaces (sub-project) is a NextJS application, and the recommended tsconfig.json includes the following configuration property:

"noEmit": true

However, this option is not compatible with project references in Typescript. I am facing this situation where NextJS needs noEmit: true because it handles the code transpilation and bundling internally, but Typescript only allows noEmit: false when I run npx tsc --build from the root directory.


This question does not require my tsconfig.json or any other setup files because the source of this issue is that specific option. Is there a way to add a NextJS application to a composite project? Are there some sample projects?. The ideal answer would treat both CommonJS and ESmodule types, but I am more interested in the CommonJS module type.


My research

I searched among the NextJS example projects but nothing. I found a similar GitHub issue, but unfortunately, it did not receive the deserved attention. Following this link from that topic, I found a paragraph about NextJS setup, but the article is 3 years old and the proposed solution is deprecated.


Solution

  • @JaredSmith correctly pointed out that a NextJS project probably does not need tsc to emit JS code. The solution is to separately compile the other dependencies and let NextJS take care of the app build. This answer wants to be a template for the configuration of a composite project that includes NextJS.


    NPM Workspaces

    Assuming you have just 2 projects (workspaces): api and next-app. next-app depends on the api project. This is the output of tree -L 1:

    .
    ├── jest.config.base.ts # see later
    ├── jest.config.ts      # see later
    ├── node_modules/
    ├── package.json
    ├── package-lock.json
    ├── packages/
    ├── tsconfig.base.json  # see later
    └── tsconfig.json
    

    The package.json file contains the workspaces' folder:

    {
      "workspaces": [
        "packages/api",
        "packages/next-app"
      ],
      "name": "...",
      ...
      "devDependencies": {
        "@jest/globals": "^29.7.0",
        "@types/jest": "^29.5.12",
        "jest": "^29.7.0",
        "ts-jest": "^29.2.5",
        "typescript": "^5.5.3"
      },
      "dependencies": {
        "eslint": "^8.57.0"
      }
    }
    

    dependencies and devDependencies in the root package.json are available for all workspaces. packages/api/package.json and packages/next-app/package.json are regular package.json.


    Typescript composite project

    To configure typescript with composite projects, first create a tsconfig.json in the root directory:

    {
        "references": [
            {
                "path": "packages/api"
            },
            {
                "path": "packages/next-app"
            }
        ],
        "include": [
            "packages/*"
        ],
        "exclude": [
            "packages/next-app"
        ]
    }
    

    references should contain the path of folders or tsconfig files included in the composite project. Notice that the next-app project is excluded.

    Then create another tsconfig.base.json file with the common configurations for all your projects. It is imported in packages/api/tsconfig.json and packages/next-app/tsconfig.json:

    {
        "compilerOptions": {
            "incremental": true,
            "composite": true,
            // The following properties can be customized
            "target": "ES6",
            "allowJs": true,
            "declaration": true,
            "declarationMap": true,
            "esModuleInterop": true,
            "skipLibCheck": true
        }
    }
    

    The composite: true property is mandatory.

    Configure api referenced project

    packages/api/tsconfig.json:

    {
        "extends": "../../tsconfig.base.json",
        "include": [
            "src/**/*.ts"
        ],
        "compilerOptions": {
            "outDir": "dist",
            "rootDir": "src",
            "module": "NodeNext",
            "moduleResolution": "NodeNext"
        }
    }
    

    We need to import tsconfig.base.json located in the root folder, then we customize the tsc compiler options.

    Configure next-app referenced project

    packages/next-app/tsconfig.json:

    {
      "extends": "../../tsconfig.base.json",
      "references": [
        {
          "path": "../api"
        }
      ],
      "include": [
        "src/**/*",
        "next.config.mjs",
        ".next/types/**/*.ts"
      ],
      "compilerOptions": {
        "lib": [
          "dom",
          "dom.iterable",
          "esnext"
        ],
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "resolveJsonModule": true,
        "jsx": "preserve",
        "plugins": [
          {
            "name": "next"
          }
        ],
        "paths": {
          "@/*": [
            "./src/*"
          ]
        },
        "strict": false,
        "noEmit": true,
        "isolatedModules": true
      },
      "exclude": [
        "node_modules"
      ]
    }
    

    NextJS typically requires a more complex configuration. The additional property is references, which points to the dependency folder. (Again, through the extends property we import the root tsconfig.json).


    Build the entire project

    To build the entire project I have prepared a simple ./Makefile:

    SERVER_PORT ?= 3001
    .PHONY: compile test start
    
    
    compile:
        npx tsc --build
        cd packages/next-app && npx next lint
    
    start: compile
        cd packages/next-app && npx next dev -p $(SERVER_PORT)
    
    test: compile
        npx jest
    

    Run make start to compile the project and start the NextJS server.


    Bonus: Testing (with Jest) in Monorepos

    The logic behind Jest configuration is similar to the tsconfig.json one. In the root folder, create jest.config.js:

    import type { Config } from 'jest';
    
    const config: Config = {
        projects: [
            '<rootDir>/packages/api',
            '<rootDir>/packages/next-app',
        ]
    };
    
    export default config;
    

    Where projects contains the sub-projects' folders. Then create a jest.config.base.ts with the common configurations:

    import type { Config } from 'jest';
    
    const config: Config = {
        testEnvironment: "node",
        transform: {
            "^.+.tsx?$": ["ts-jest", {}],
        }
    };
    
    export default config;
    

    Each sub-project referenced in the projects property must contain a jest.config.ts file in its root folder, but can extend the base one:

    import type { Config } from 'jest';
    import baseConfig from '../../jest.config.base'
    
    const config: Config = {
        ...(baseConfig as Config),
        // other properties
    };
    
    export default config;
    

    With the previous Makefile, run make test and tests from all the specified projects are executed. The only issue I found is related to the working directory with the fs module: the base path is . instead of ./packages/my-prj. I solved it by concatenating the current file directory:

    import * as path from 'path'
    path.resolve(__dirname, './relative-path')
    

    I honestly do not know whether this is a bug, but the solution I found is just a workaround and dangerous in case you change the project structure. I believe the alternative of running npx jest inside each sub-project remains valid.