Sharing TypeScript with Nx and Turborepo: Configuring a Monorepo

Part 3: How to configure popular development tools for an Nx or Turborepo monorepo

Joseph T. Lapp
JavaScript in Plain English

--

Sharing TypeScript — Part 3: Configuring a Monorepo, by Joe Lapp

This is the third article in a series that explains how to use Nx and Turborepo monorepos to share code and configuration across multiple TypeScript projects. In this article, we explain how to configure a variety of popular development tools for use in monorepo workspaces.

Each development tool has its own configuration conventions, and few of them were developed with monorepos in mind. This makes configuring any particular tool for use in a monorepo its own research project, each with its own challenges to overcome. This article is a compilation of everything I’ve learned configuring various popular development tools for use in monorepos. I tested these configurations with Next.js, SvelteKit, and VS Code, keeping track of the specifics necessary for each.

Table of Contents

Approaches to Sharing Configuration
Sharing TypeScript Configuration
Sharing Prettier Configuration
Sharing ESLint Configuration
Sharing Jest Configuration
Sharing Vitest Configuration
Sharing Tailwind CSS Configuration
What Next?

Approaches to Sharing Configuration

There are two approaches to sharing dev tool configuration in a monorepo. One approach delegates configuration to a workspace of the monorepo, declaring that workspace as a dependency of each project that uses the configuration. As we’ll see, this approach is best for development teams working on large projects. The other approach puts the configuration in the monorepo root (or some subdirectory) and references this configuration from within each workspace via a relative file name. This approach is fine for small projects having only one or two developers.

The example monorepo we’ve been using delegates configuration to workspaces. Here are its configuration workspaces and their files:

eslint-config-custom
index.js
package.json
tailwind-config
package.json
tailwind.config.js
tsconfig
base.json
nextjs.json
package.json
react-library.json

With the exception of ESLint workspaces, you are free to choose the names of the configuration workspaces. These workspaces contain one or more configuration files that other projects can reference. For example, this example tsconfig workspace contains base.json, nextjs.json, and react-library.json, which are just tsconfig.json files having different names. nextjs.json and react-library.json each extend base.json to produce variations of it.

Projects that wish to use these configurations include the workspace names in their dev dependencies and then extend the configurations from project-local configuration files. For example, the web application in the example monorepo has the following dev dependencies:

// a project package.json
{
// ...
"devDependencies": {
// ...
"eslint-config-custom": "workspace:*",
"tailwind-config": "workspace:*",
"tsconfig": "workspace:*"
}
}

The second approach to sharing dev tool configuration just places the configuration files somewhere in the monorepo and has the configuration files in each project refer to them using relative path names. Under this approach, if you change a shared configuration, the monorepo manager won’t know that the configuration’s dependent projects need to be rebuilt. You’ll have to force a rebuild by clearing Nx or Turborepo’s caches.

Some dev tools will walk up the directory tree looking for the nearest configuration file, such as a file in the monorepo root. This may incline you to share configuration by placing it in the root without also explicitly configuring each nested workspace directory to use the shared file. This won’t work with the VS Code workspaces feature, described in the next article of this series. This feature treats each workspace as the root of a project and requires each workspace to be locally configured.

However, it is generally best to place each configuration in its own workspace and have each project that uses a configuration depend on that configuration’s workspace. This way, the monorepo manager will know which projects to rebuild when a particular configuration changes.

After placing configuration files in a shared location according to either approach, we need to point each project to its configuration files. The technique varies by the type of configuration. The remainder of this article explains how this is done for some common dev tools.

Sharing TypeScript Configuration

To use a shared TypeScript configuration, places a tsconfig.json in the project and have its extends key identify the shared configuration file. The project’s tsconfig.js should also provide include and exclude keys to indicate which project-local files to include and exclude from compilation. These latter two keys can’t be shared because they define files and directories relative to the configuration file itself.

The web application in our example extends a shared nextjs.json tsconfig by including the following tsconfig.json in the application’s workspace:

// a project tsconfig.json
{
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

Here, tsconfig/nextjs.json refers to the nextjs.json file in the tsconfig workspace. Contrary to the Turborepo template, if you were to instead name the shared configuration file tsconfig.nextjs.json, VS Code would be able to provide autocompletion assistance when editing the file.

If you don’t want the shared TypeScript configuration in a configuration workspace, you can simply refer to it with a relative path name. For example, if the shared configuration file were in the monorepo root and the project were in the directory apps/myproject, the following tsconfig.json would configure a Next.js project to use it:

// a Next.js workspace tsconfig.json
{
"extends": "../../tsconfig.nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

Notice that this tsconfig.json does not specify the build output directory. This is because it is built via the next build command, which outputs to .next/ in the project’s workspace. Most other projects would instead have their tsconfig.json files specify the output directory. For example:

// a typical workspace tsconfig.json
{
"extends": "../../tsconfig.nextjs.json",
"compilerOptions": {
"lib": ["ES2019"],
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["*.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["dist", "build", "node_modules"]
}

On the other hand SvelteKit provides a tsconfig.json file that the project must use. There are two ways to extend this configuration with your own TypeScript preferences. One way is to use a typescript key in the svelte.config.js file. This key allows you to provide a function that mutates the SvelteKit-provided configuration. The other way is simpler, as a tsconfig.json can extend multiple other configuration files.

Using the simpler approach, here are examples of how we can extend SvelteKit with our own TypeScript configuration:

// SvelteKit project with dependent tsconfig workspace
{
"extends": [
"./.svelte-kit/tsconfig.json",
"tsconfig/tsconfig.svelte-kit.json"
]
}
// SvelteKit project with path-relative tsconfig
{
"extends": [
"./.svelte-kit/tsconfig.json",
"../../tsconfig.svelte-kit.json",
]
}

Neither of these configuration files has the include or exclude keys because the project’s .svelte-kit/tsconfig.json provides them.

Sharing Prettier Configuration

Sharing Prettier configuration is straightforward, but you’ll first want to decide whether to share Prettier configuration on a per-project basis or not. Unlike most dev tools, Prettier will use the .prettierrc.json found in the root of the monorepo even within VS Code workspaces.

If you’re using the same configuration across the entire monorepo and don’t care to improve performance by caching Prettier output, you can just have a root .prettierrc.json and not bother using Prettier with the monorepo manager. Also, as of this writing, Prettier does not let you pre-configure which files to pretty — you have to specify these files in each command. This is another reason you might only allow prettying from the monorepo root, via a pnpm script, so you’re not having to work to keep the prettying scripts consistent across the projects.

If you’d rather control Prettier configuration on a per-project basis, sharing configuration across projects, you can either delegate to a configuration workspace or provide a relative path to the shared configuration file.

Let’s first look at delegating to a configuration workspace. Suppose we have a workspace called prettier-config and we want to delegate to the prettierrc.json file in this workspace. In this case, place the following two files in the prettier-config workspace:

// prettier-config/package.json
{
"name": "prettier-config",
"private": true,
"main": "prettierrc.json"
}
// prettier-config/prettierrc.json
{
"singleQuote": true,
"trailingComma": "es5"
}

Now add a prettier key to the package.json of each project workspace that is to use the configuration:

// workspace package.json
{
// ...
"prettier": "prettier-config",
// ...
}

The value of the prettier key must be a workspace name and cannot be a relative file path. To share multiple different Prettier configurations, use multiple Prettier configuration workspaces, each appropriately named. To each project, add a dev dependency to its Prettier workspace.

We can also configure Prettier with relative paths to configuration files. The files can have any names we want, but they must have a .js or .json extension. (In particular, they can't be .prettierrc with no extension.) Then in each workspace that is to use a Prettier configuration, add a .prettierrc.js file of the following form:

module.exports = {
...require('../../name-of-prettier-config-file.json'),
// you can override the base configuration here
};

In the Turborepo template monorepo we’ve been examining, the root package.json has a format script that uses Prettier to format all source files in the monorepo, but it relies on the default Prettier configuration. Hence, this template provides no Prettier configuration.

Note that if you’re using VS Code, you may need to reload the window to get Prettier configuration changes to take effect. Be sure not to hardcode the location of the Prettier configuration into the VS Code settings.

Sharing ESLint Configuration

ESLint provides many mechanisms for sharing configuration. However, it seems that when a monorepo delegates ESLint configuration to one of its own workspaces, the workspace directory must start with the prefix eslint-config-. Moreover, it appears to be convention for an unpublished ESLint configuration workspace to have either the name eslint-config-custom or a name starting with eslint-config-custom-.

It’s best to have one configuration workspace for each different ESLint configuration. For example, if you’re using React but also have workspace projects that don’t depend on React, you’ll want an eslint-config-custom-react workspace, along with an eslint-config-custom workspace for the non-React projects. Likewise, if you’re using Svelte, you’ll want an eslint-config-custom-svelte workspace for Svelte-specific ESLint configuration.

Here is an example eslint-config-custom workspace for use with general-purpose TypeScript libraries:

// eslint-config-custom/package.json

{
"name": "eslint-config-custom",
"version": "0.0.0",
"main": "index.js",
"dependencies": {
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/parser": "^5.53.0"
}
}
// eslint-config-custom/index.js

module.exports = {
root: true,
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": "off",
},
};

To have a project use this ESLint configuration, add "eslint-config-custom": “workspace:*" to the project’s dev dependencies, and place the following .eslintrc.json file in its workspace:

{
"root": true,
"extends": ["custom"]
}

The root key indicates that ESLint should not continue to walk up the directory tree looking for additional configuration files to apply. The extends key identifies the suffix of the ESLint configuration workspace. It prefixes the suffix with eslint-config- to fully name the workspace.

Now lets’ look at Next.js and React. By default Next.js uses the following ESLint in an application workspace:

// Next.js app .eslintrc.json

{
"extends": "next/core-web-vitals"
}

extends can take an array, so you can modify this configuration with your own preferences by adding a reference to a custom ESLint configuration.

You may want to specify your own configuration for component library workspaces. Here is an example an eslint-config-custom-react workspace you can use with component libraries for Next.js:

// eslint-config-custom-react/package.json

{
"name": "eslint-config-custom-react",
"version": "0.0.0",
"main": "index.js",
"dependencies": {
"eslint-config-next": "13.4.2",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-react": "7.28.0",
"next": "13.4.2"
}
}
// eslint-config-custom-react/index.js

module.exports = {
extends: ["next", "prettier"],
rules: {
"@next/next/no-html-link-for-pages": "off",
},
parserOptions: {
babelOptions: {
presets: [require.resolve("next/babel")],
},
},
settings: {
next: {
rootDir: ["apps/*/"],
},
},
};

Each component library can use this configuration by including the following .eslintrc.json, in addition to adding "eslint-config-custom-react": “workspace:*" to the project’s dev dependencies:

module.exports = {
root: true,
extends: ["custom-react"],
};

Finally, let’s see how we might configure ESLint for Svelte. SvelteKit provides an ESLint configuration on installation. This configuration can appear in component libraries too, because each component library can be an installation of SvelteKit. One can move this to a shared configuration workspace for use in multiple projects. Doing so requires installing the required packages into the configuration workspace. Here are the files we might use in an eslint-config-custom-svelte configuration workspace:

// eslint-config-cusotm-svelte/package.json

{
"name": "eslint-config-custom-svelte",
"version": "0.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/parser": "^5.53.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte": "^2.30.0"
}
}
// eslint-config-cusotm-svelte/index.js

module.exports = {
root: true,
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:svelte/recommended",
"prettier",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
extraFileExtensions: [".svelte"],
},
env: {
browser: true,
es2017: true,
node: true,
},
overrides: [
{
files: ["*.svelte"],
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
},
},
],
rules: {
// ...
},
};

To have a project use this ESLint configuration, add "eslint-config-custom-svelte": “workspace:*" to the project’s dev dependencies, and place the following .eslintrc.json file in its workspace:

{
"root": true,
"extends": ["custom-svelte"]
}

Run pnpm install from the root of the monorepo install all of the new package.json dependencies. If you’re using VS Code, you may need to reload the window to get ESLint configuration changes to take effect.

Be aware that ESLint’s extends key does not allow for qualifying ESLint configuration package names with an ‘@’-prefixed organization.

If you want to reference path-relative configuration files, place the relative path name in the extends key of each project's .eslintrc.json.

Sharing Jest Configuration

Shared Jest configuration is called a “preset” and contains any of the options available to a normal Jest configuration file. However, there are some special rules to abide by when configuring Jest for a monorepo, as we’ll explain in this section.

Under path-relative configuration, you can give the preset file any name you want, as long as it has an extension of .js or .json. (Other extensions are possible, but we won't be covering them here.) If you simply delegate configuration to a workspace, the file must be called jest-preset.js or jest-preset.json. To have multiple base configurations in a single configuration workspace, you have to put each file (of this same name) in its own directory and have the directory names distinguish them. For example, you might have a browser/jest-preset.js and a node/jest-preset.js in a workspace for frontend and backend Jest configurations.

Here is an example preset that will run TypeScript test files, compiling them on-the-fly using ts-jest:

// jest-preset.js
module.exports = {
roots: ['<rootDir>'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: [
'<rootDir>/test/__fixtures__',
'<rootDir>/node_modules',
'<rootDir>/dist',
],
preset: 'ts-jest',
};

There are two ways to apply a Jest preset to a project workspace. One way is to add one of the files jest.config.js or jest.config.json to the workspace root. The file would reference the appropriate base preset and include any configuration that should override the base preset. Here is an example using path-relative configuration:

// project jest.config.js
module.exports = {
displayName: 'someproject',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
coverageDirectory: '../../coverage/packages/myworkspace',
};

<rootDir> appears literally in the configuration and refers to the project's workspace directory. This particular configuration tells ts-jest to compile tests using the tsconfig.spec.json file found in the project workspace, instead of using the local tsconfig.json. It also uses the monorepo root jest.preset.js preset, found two directories up.

If you want to use a configuration workspace preset, instead set the preset key to the configuration workspace name (and subdirectory, if any). The value of the key does not include the name of the jest-preset.js or jest-preset.json file found in this directory:

// project jest.config.js
module.exports = {
// ...
preset: 'jest-presets/node',
// ...
};

The other way to apply a preset to a workspace is to dispense with the jest.config.js file and put all of the configuration in the workspace's package.json. Just add a jest key to the package and set its value to the desired Jest configuration. In the following example, the configuration is a modification of a root-level preset:

// project package.json based on a modified root-level preset
{
// ...
"jest": {
"displayName": "myworkspace",
"preset": "../../jest.preset.js",
"globals": {
"ts-jest": {
"tsconfig": "<rootDir>/tsconfig.spec.json"
}
},
"coverageDirectory": "../../coverage/packages/myworkspace"
}
// ...
}

In the next example of this approach, the configuration uses a workspace preset unmodified. In order for the preset key to reference the jest-presets workspace, you must declare the workspace as a dependency:

// project package.json using a workspace preset unmodified
{
// ...
"jest": {
"preset": "jest-presets/node"
},
// ...
"devDependencies": {
// ...
"jest-presets": "workspace:*"
}
}

You’ll likewise need to include the presets workspace dependency in the project’s package.json in order to be able to reference the workspace from jest.config.js. The package.json for the presets workspace itself could be as simple as this:

// presets workspace package.json
{
"name": "jest-presets",
"private": true
}

My experimentation suggests that the preset key always refers to a workspace unless it is a relative path beginning with ./ or ../.

Sharing Vitest Configuration

It is easy to share Vitest configuration among workspaces. First, create the shared configuration as either a file in a common configuration directory or as a shared configuration workspace. For example, we could create a vitest-config workspace with the following files:

// vitest-config/package.json

{
"name": "vitest-config",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"vitest": "^0.32.2"
}
}
// vitest-config/vitest.config.js

import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [],
test: {
include: ["**/*.test.ts", "**/*.spec.ts"],
globals: true,
},
});

Then import this configuration from either a vite.config.js or a vitest.config.js file in the root of each project workspace requiring the configuration. Use the JavaScript import syntax, and further refine the configuration if you like. For example, here is a project configuration that restricts tests within the project to running one file at time and one test at a time, equivalent to Jest’s runInBand option:

// project vitest.config.js

import { defineConfig, mergeConfig } from "vitest/config";
import baseConfig from "vitest-config/vitest.config.js";

export default mergeConfig(
baseConfig,
defineConfig({
test: {
isolate: true,
minThreads: 1,
maxThreads: 1,
},
})
);

Each project must install both of the development dependencies vitest and vitest-config in order to function properly.

Last, add the following line to the TypeScript configuration to enable the Jest-compatible globals:

// tsconfig.json

{
"compilerOptions": {
// ...
"types": ["vitest/globals"],
}
}

Sharing Tailwind CSS Configuration

To share Tailwind CSS configuration across monorepo workspaces, first place your tailwind.config.js file in a shared location. This can be either a configuration workspace or a shared file, as discussed earlier.

The Tailwind configuration file must reference all of the source code that depends on the configuration. Any files not referenced will have their Tailwind CSS classes stripped by tree shaking during the build. Reference these files using the content keyword, as illustrated in the following example, which applies Tailwind to the HTML, Svelte, and TSX files in the apps/website and libs/ui project workspaces:

/** @type {import('tailwindcss').Config} */
export default {
content: [
'../../apps/website/src/**/*.{html,js,svelte,ts,tsx}',
'../../libs/ui/src/**/*.{html,js,svelte,ts,tsx}',
],
theme: {
extend: {}
},
plugins: []
};

Now we’ll add postcss.config.js and tailwind.config.js files to each project workspace that needs to use the shared configuration. The postcss.config.js file identifies the tailwind.config.js file to use, so we need to place a postcss.config.js file in each project that uses Tailwind. It defaults to using the tailwind.config.js found in the same directory. We’ll keep this default but point that file to the shared configuration. (I wasn’t able to find a reliable way to share the postcss.config.js file itself.)

Finally, place a tailwind.config.js file in each project, having it reference the shared configuration. To share via relative paths, model the file on the following example:

// tailwind.config.js
module.exports = require('../../tailwind.config.js');

To share via a dependent workspace, model the file on this example, where tailwind-config is the name of the configuration workspace:

// tailwind.config.js
module.exports = {
presets: [require("tailwind-config")],
};

This latter example assumes that the main key of the configuration workspace’s package.json identifies the configuration file within the workspace (e.g. "main": "tailwind.config.js"). You can have multiple configurations in the configuration workspace if you directly reference the file (e.g. require("tailwind-config/tailwind.config.svelte.js”)).

Using presets in your Tailwind configuration allows a project to override or extend one or more base configurations.

(See the next article in this series for additional configuration that’s helpful for using Tailwind in a monorepo with Svelte and VS Code.)

What Next?

We’ve seen how there are two general approaches to configuring projects. Under one approach, we simply have each project reference configuration files via relative path names. Under the other, we put each configuration in its own workspace, make projects dependent on these workspaces, and have each project’s configuration reference the configuration workspaces on which it depends. The first approach requires manually forcing rebuilds on configuration changes, while the second allows the monorepo manager to know which projects to rebuild when configurations change. Beyond this, each dev tool has its own requirements for use in monorepos, which we covered in detail for some of the popular tools.

In the next and final article of this series, Part 4: Managing a Monorepo, we cover a variety of topics associated with managing monorepos.

More content at PlainEnglish.io.

Sign up for our free weekly newsletter. Follow us on Twitter, LinkedIn, YouTube, and Discord.

--

--

Sr. Software Engineer. Full stack TS/Node/Svelte and performant Java. Patents, specs, UML, tutorials. Learning Rust and DDD. JoeLapp.com