Sharing ESLint, TypeScript, and Tailwind Configs in a Turborepo Monorepo: A Practical Guide to the config-* Pattern
Shortly after setting up a monorepo, you've probably experienced this: you update an ESLint rule in apps/web, it doesn't get applied to apps/admin, and CI breaks. The moment you start copy-pasting config files around, the "single source of truth" disappears, and you're left maintaining files you can barely keep track of.
I used to manage a separate tsconfig.json for each app, but after having to touch 5 files just to change one rule, I knew something was wrong. The packages/config-* pattern that Turborepo officially recommends is exactly the solution to this problem. By the end of this post, you'll be able to set up a standard ESLint, TypeScript, and Tailwind environment for a new app with just three lines in devDependencies.
Prerequisites: This post is aimed at intermediate or above developers who are familiar with the basics of pnpm workspaces and have some experience with Turborepo. If you're new to Turborepo, we recommend checking out the official Quick Start first. Since both ESLint (v9) and Tailwind CSS (v4) went through major changes between 2024 and 2025, we'll also cover the latest trends.
Core Concepts
Why Separate Configs into Dedicated Packages
There are two main ways to share configuration in a monorepo: put one config file at the root that all apps inherit from, or have each app manage its own config independently. The former is simple but inflexible when apps need different settings; the latter leads to an explosion of duplication.
The packages/config-* pattern sits in the middle. It turns configs into separate internal packages, and each app picks and references only what it needs.
monorepo/
├── apps/
│ ├── web/ ← Next.js app
│ └── admin/ ← Admin app
└── packages/
├── config-eslint/ # @repo/eslint-config
├── config-typescript/ # @repo/typescript-config
├── config-tailwind/ # @repo/tailwind-config
└── ui/ # Shared UI componentsJust-in-Time Package Strategy
The key characteristic of config packages is that there is no build step. A typical library package builds TypeScript source into a dist/ folder and consumers use that. Config packages directly reference the source files themselves, and the consuming app processes them at compile time.
JIT (Just-in-Time) Package: An internal package that exposes source files directly without its own build step. Turborepo doesn't generate a build cache for it, but config changes are immediately reflected to all consumers.
Connecting with Workspace References
If you're using pnpm, declare the package paths in pnpm-workspace.yaml and reference them from each app's package.json using the workspace:* protocol.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// apps/web/package.json
{
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@repo/tailwind-config": "workspace:*"
}
}workspace:* means "don't look in the npm registry, find it in the local workspace." No need to match versions — pnpm automatically creates symbolic links. The convention for config packages is to set "version": "0.0.0" in their package.json and add "private": true to prevent them from being published externally.
Practical Implementation
Sharing TypeScript Config
It's recommended to start with the TypeScript config package. Since there's no build and you only need to expose .json files, the scope of change is smallest, and it's easy to introduce incrementally into an existing monorepo.
// packages/config-typescript/package.json
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"files": ["base.json", "nextjs.json", "react-library.json"]
}base.json is the minimal foundation that all packages inherit from. The key point is enabling strict mode — managing this through a shared config prevents situations like "this app is strict, that one isn't."
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true
}
}Splitting files by purpose lets each app extend only the config that suits it. nextjs.json adds the options Next.js requires on top of base.json.
// packages/config-typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"allowJs": true,
"jsx": "preserve",
"incremental": true
}
}// apps/web/tsconfig.json
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}| File | Purpose |
|---|---|
base.json |
Common base config for all packages |
nextjs.json |
Config optimized for Next.js apps (extends base) |
react-library.json |
Config for React library packages (extends base) |
Note: The Turborepo team announced in 2024 that "TypeScript Project References may be unnecessary." A simple
extends-based sharing strategy is sufficient in most cases, and Project References often just add configuration complexity. I tried adding Project References at first and eventually reverted — the hassle wasn't worth the benefit.
TypeScript is this straightforward, but ESLint requires a bit more consideration — especially since there has been a major shift to Flat Config since 2024.
Sharing ESLint Flat Config
Honestly, ESLint config sharing changed quite a bit starting in 2024. ESLint v8 reached EOL (End of Life) on October 5, 2024, and from ESLint 9 onward, the Flat Config format based on eslint.config.js is the default. If you're used to the old .eslintrc approach, migration is required.
When setting up the package, missing the "type": "module" declaration and the dependency declarations will immediately trip up anyone following along. Be sure to include them.
// packages/config-eslint/package.json
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./base": "./src/base.js",
"./next": "./src/next.js",
"./react-internal": "./src/react-internal.js"
},
"dependencies": {
"@eslint/js": "^9.0.0",
"eslint-plugin-turbo": "^2.0.0"
},
"peerDependencies": {
"eslint": ">=9.0.0"
}
}The "type": "module" declaration is especially important. Flat Config operates in ESM mode, and without it, .js files are interpreted as CommonJS, leading to unexpected errors. I spent a long time stuck on this myself.
// packages/config-eslint/src/base.js
import js from "@eslint/js";
import turboPlugin from "eslint-plugin-turbo";
export const baseConfig = [
js.configs.recommended,
{
plugins: { turbo: turboPlugin },
rules: {
"turbo/no-undeclared-env-vars": "warn",
},
},
];When adding Next.js-specific config, be aware that how eslint-config-next supports Flat Config can vary by Next.js version. The following shows how to bridge via FlatCompat from @eslint/eslintrc for cases like Next.js 14.x and earlier where native Flat Config support isn't available yet.
// packages/config-eslint/src/next.js
// (For Next.js 14.x and below, or when eslint-config-next doesn't support flat config)
import { FlatCompat } from "@eslint/eslintrc";
import { baseConfig } from "./base.js";
const compat = new FlatCompat();
export const nextjsConfig = [
...baseConfig,
...compat.extends("next/core-web-vitals"),
];Using it in an app looks like this:
// apps/web/eslint.config.mjs
import { nextjsConfig } from "@repo/eslint-config/next";
export default [...nextjsConfig];If you have existing plugins that don't yet support Flat Config, the @eslint/compat package can help. I used this approach when migrating an internal custom plugin at work, and in most cases it worked without any code changes.
// packages/config-eslint/src/base.js (example with legacy plugin)
import { fixupPluginRules } from "@eslint/compat";
import legacyPlugin from "some-legacy-eslint-plugin";
export const baseConfig = [
// ...existing config
{
plugins: {
legacy: fixupPluginRules(legacyPlugin),
},
},
];Once ESLint is sorted, it's time for Tailwind. Since v3 and v4 work quite differently, we'll cover both.
Sharing Tailwind CSS Config
Tailwind v3 approach — sharing via spread
In v3, the most common approach is to write a tailwind.config.ts and import it with a spread in each app. The key here is intentionally omitting content. Since file paths differ per app, including content in the shared config would make all apps point to the same paths.
// packages/config-tailwind/tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Omit<Config, "content"> = {
theme: {
extend: {
colors: {
brand: "#0070f3",
},
},
},
plugins: [],
};
export default config;// apps/web/tailwind.config.ts
import type { Config } from "tailwindcss";
import sharedConfig from "@repo/tailwind-config";
const config: Config = {
...sharedConfig,
content: [
"./src/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}", // Explicitly list the shared UI package path
],
};
export default config;If you don't include the shared UI package path in content, the Tailwind classes used in that package become targets for purging (unused class removal) in production builds. You'll have styles that look fine in development and then mysteriously disappear after a production deployment. Always include it.
Tailwind v4 approach — CSS file-based config
In v4, tailwind.config.js is gone and configuration lives inside CSS files. When exposing a shared CSS file from an external package, you need to specify the CSS file path in the exports field of package.json. Leaving this out will break @import, which can be confusing when setting up for the first time.
// packages/config-tailwind/package.json
{
"name": "@repo/tailwind-config",
"version": "0.0.0",
"private": true,
"exports": {
"./theme.css": "./theme.css"
}
}/* packages/config-tailwind/theme.css */
@import "tailwindcss";
@theme {
--color-brand: #0070f3;
--font-sans: "Inter", sans-serif;
}v4's automatic content scanning only detects the current package's scope. If you're using a shared UI package, you need to explicitly specify the path with the @source directive.
/* apps/web/src/styles/globals.css */
@import "@repo/tailwind-config/theme.css";
/* Explicitly tell Tailwind to scan the shared UI package */
@source "../../../packages/ui/src";| Version | Config Method | Monorepo Sharing Key |
|---|---|---|
| v3 | tailwind.config.ts spread |
Share without content; each app specifies its own paths |
| v4 | CSS @theme, @source directives |
Specify CSS path in exports, then add paths via @import and @source |
Pros and Cons
Advantages
| Item | Description |
|---|---|
| Consistency | All apps use the same rules and configs, ensuring uniform code quality |
| Simplified maintenance | Changing a config in one place propagates everywhere |
| Fast propagation | The JIT approach delivers changes to consumers immediately without a build |
| Easy onboarding | Three lines in devDependencies is all it takes to get a standard setup for a new app |
| Flexible extension | Use a shared base and customize per app as needed |
Drawbacks and Caveats
| Item | Description | Mitigation |
|---|---|---|
| No JIT caching | Config packages don't generate a Turborepo build cache, so if config changes frequently, downstream packages rebuild every time | Batch config changes into meaningful units and apply them all at once |
| Bundler dependency | The JIT approach requires the consuming app to be able to process TypeScript through a bundler | Not suitable for packages run directly in Node.js without a bundler |
| ESLint v9 migration cost | Migrating to Flat Config can cause compatibility issues with existing .eslintrc-based plugins |
Can be handled with fixupPluginRules from @eslint/compat |
| Tailwind v4 scan scope | Automatic content detection doesn't pick up shared UI packages | Explicitly add paths using the @source directive |
The paths alias issue also comes up in practice. If you use TypeScript's compilerOptions.paths aliases inside a JIT package, consumers won't be able to resolve them. For TypeScript 5.4 and above, it's recommended to use subpath imports via the imports field in package.json (a Node.js feature that lets you define aliases for paths internal to a package using #-prefixed names like #utils).
The Most Common Mistakes in Practice
-
Including
contentpaths in the Tailwind config package: In v3, puttingcontentin the shared config makes all apps point to the same paths.contentmust always be specified in each app'stailwind.config.ts. -
Missing
"type": "module"in the ESLint shared package: Flat Config operates in ESM mode, and without this, import statements are interpreted as CommonJS, causingSyntaxError: Cannot use import statement. -
Tailwind classes from the shared UI package disappearing in production builds: If the shared UI package path is missing from the app's
contentsetting (v3) or@sourcedirective (v4), styles that look fine in development will vanish after a production deployment. It's safer to explicitly include the path in the form../../packages/ui/src/**/*.{ts,tsx}.
Closing Thoughts
The packages/config-* pattern is a strategy that fundamentally reduces configuration complexity in a monorepo. The initial setup takes a bit of time, but afterwards you can focus purely on business logic when adding new apps without worrying about config. Since adopting this structure, I've noticed onboarding time decrease noticeably.
If starting a new project, begin with step 1. If introducing this into an existing project, start with step 2.
-
Try running
npx create-turbo@latest my-monorepoto generate the scaffolding. Theconfig-eslintandconfig-typescriptpackages are created automatically, so you can see the actual structure right away. -
If applying to an existing monorepo, it's recommended to start with the TypeScript config. Write
packages/config-typescript/base.jsonand add"extends": "@repo/typescript-config/base.json"to existing apps'tsconfig.json. This has the smallest scope of change. -
Before migrating ESLint, it's worth checking the current version. If you're on v8 or below, it's advantageous in the long run to consider migrating to the v9 Flat Config at the same time, and the
@eslint/migrate-configtool can help convert your existing.eslintrcconfig to Flat Config.
In the next installment, we'll cover Turborepo pipeline optimization — how to reduce build times across the entire team with turbo.json caching strategies and Remote Cache setup.
References
- Internal Packages | Turborepo
- Structuring a Repository | Turborepo
- Managing Dependencies | Turborepo
- ESLint Guide | Turborepo
- TypeScript Guide | Turborepo
- Tailwind CSS Guide | Turborepo
- You might not need TypeScript project references | Turborepo Blog
- How to Create an ESLint Config Package in Turborepo | DEV Community
- Setting up Tailwind CSS v4 in a Turbo Monorepo | Medium
- Building a Scalable Frontend Monorepo with Turborepo, Vite, TailwindCSS V4 | DEV Community
- Monorepo Setup with Turborepo - Complete Guide | NashTech Blog
- Creating an Internal Package | Turborepo