Migrating from Nx to Turborepo: A Real-World Guide for Next.js 15
Monorepos are a double-edged sword. While they promise unified tooling, the abstraction layers can eventually become a bottleneck. Our team recently reached that breaking point with Nx.
While Nx is a powerhouse, the "Nx way" of doing things—custom executors, hidden build logic, and complex caching—began to feel like a black box that hindered our team’s autonomy. We decided to migrate to Turborepo for a simpler mental model: a task runner that stays out of the way.
In this guide, I’ll walk you through our exact migration path for a Next.js 15 / React 19 stack, including the "nightmare" Jest configurations and SVG issues that the official docs don't warn you about.
Why We Left Nx
Our setup for our core apps was suffering from:
- Single repository complexity: All apps lived in one repo, making it hard to manage independent deployments.
- Tight Coupling: A change in a shared library triggered unnecessary builds across the entire suite.
- Nx overhead: Nx added significant tooling complexity, custom executors, and abstraction layers that were hard to debug.
- Team autonomy: Different teams couldn't work independently on their apps.
Why Turborepo
- Simpler mental model: Turborepo is a task runner, not a framework — it does less but does it transparently
- Better polyrepo support: Each app can eventually live in its own repo while sharing Turborepo pipeline config
- Faster builds: Turborepo's caching is simpler to configure and works well out of the box
- No vendor lock-in: Unlike Nx, Turborepo doesn't wrap standard tools with custom executors
Migration Steps
Step 1: Project Structure Setup
What Nx had:
apps/
<appName>/
project.json ← Nx-specific config
jest.config.js
next.config.js ← used withNx, composePlugins
libs/
nx.json
workspace.json
What Turborepo needs:
apps/
<appName>/
package.json ← each app needs its own package.json
jest.config.js
next.config.js ← plain Next.js config, no Nx plugins
turbo.json ← replaces nx.json
package.json ← root package.json with workspaces
Steps taken:
- Add
turbo.jsonat root with task pipeline definitions - Ensure each app has its own
package.jsonwith a uniquenamefield - Update root
package.jsonworkspaces to include all apps
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"test": {
"outputs": ["coverage/**"],
"cache": false
},
"lint": {
"outputs": []
},
"lint:fix": {
"cache": false,
"outputs": []
},
"typecheck": {
"outputs": []
},
"prettier": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Step 2: Replacing next.config.js
What Nx had:
const { composePlugins, withNx } = require('@nx/next');
const nextConfig = {
nx: { svgr: true },
// ... config
};
module.exports = composePlugins(withNx, withBundleAnalyzer)(nextConfig);
Challenges:
-
withNxwas handling SVGR configuration automatically -
withNxwas setting distDir based on Nx'soutputPathinproject.json -
composePluginswas an Nx-specific utility -
withNxwas setting up path aliases from tsconfig automatically
Solution:
// apps/<appName>/next.config.js
const path = require('path');
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
const nextConfig = {
// Manually configure SVGR - was handled by withNx before
webpack(config) {
config.module.rules.push({
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: [{
loader: '@svgr/webpack',
options: {
exportType: 'named',
namedExport: 'ReactComponent',
// Critical: Nx was preserving viewBox by default
// SVGR strips it without this config
svgoConfig: {
plugins: [{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
cleanupIds: false,
},
},
}],
},
},
}],
});
return config;
},
};
module.exports = withBundleAnalyzer(nextConfig);
Key challenge — SVG viewBox stripped:
After replacing withNx, all SVG icons lost their viewBox attribute causing layout issues. SVGR's default SVGO config removes viewBox. Fixed by explicitly setting removeViewBox: false in SVGO config.
Step 3: Environment Variables
How Nx handled it: Nx executors set process.cwd() to the workspace root before running any task. So when Next.js looked for .env files, it found them at the repo root automatically.
How Turborepo handles it: Turborepo runs tasks from the app directory (apps/<appName>/). Next.js traverses up the directory tree looking for .env files via @next/env, so it still finds the root .env file.
Result: No changes needed — Next.js 13+ handles this automatically via directory traversal.
Step 4: Build Output & Custom Server
What Nx had:
-
@nx/next:buildexecutor automatically copied build output todist/apps/<appName>/ -
@nx/js:tscexecutor compiled the custom server -
update-package-jsonstep auto-generated a productionpackage.json -
modifyPackageJson.jspatched the start script
tsconfig.server.json fix:
"outDir": "../../dist/apps/<appName>",
"rootDir": ".", // Critical: prevents nested path duplication
Challenge — nested output paths:
Without rootDir: ".", TypeScript mirrored the full directory structure
Step 5: Jest Configuration Migration
This was the most complex part of the migration. Multiple interconnected issues had to be solved.
5.1 Config Structure
Nx had:
module.exports = {
preset: '../../jest.preset.js',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }],
},
};
Turborepo needs:
const nextJest = require('next/jest');
const createJestConfig = nextJest({ dir: __dirname }); // __dirname critical for Turborepo
module.exports = async () => {
const config = await createJestConfig(customJestConfig)();
// Must patch AFTER createJestConfig - nextJest overrides many settings
config.transform = { ... };
config.transformIgnorePatterns = [ ... ];
config.moduleNameMapper = { ... };
return config;
};
Critical insight: createJestConfig from next/jest must be exported as an async function to allow patching the final config after Next.js applies its defaults.
5.2 Path Resolution Issue
Problem: Running yarn test from repo root failed:
Error: Couldn't find any pages or app directory
Cause: dir: './apps/' resolves relative to where Jest is invoked (repo root in Turborepo), not relative to the config file.
Fix:
// Use __dirname instead of relative path
const createJestConfig = nextJest({ dir: __dirname });
const repoRoot = path.resolve(__dirname, '../..');
5.3 Transform Not Working
Problem: Setting transform in customJestConfig had no effect.
Cause: nextJest completely overrides the transform field. Custom transforms must be applied AFTER createJestConfig resolves.
Fix:
module.exports = async () => {
const config = await createJestConfig(customJestConfig)();
// Patch AFTER nextJest has set its own transforms
config.transform = {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': `${repoRoot}/mocks/svgTransformer.js`,
...config.transform,
};
return config;
};
5.4 SVG Transform Never Called
Problem: SVG TRANSFORMER HIT never appeared in logs despite correct transform config.
Root cause discovered: nextJest injects a moduleNameMapper entry that intercepts ALL SVGs before the transformer runs:
"^.+\\.(svg)$": "/node_modules/next/dist/build/jest/__mocks__/fileMock.js"
This returns { src: '/img.jpg', height: 40, width: 40 } — a static image object, not a React component.
Fix: Override the SVG moduleNameMapper AFTER createJestConfig:
config.moduleNameMapper = {
...config.moduleNameMapper,
'^.+\\.(svg)$': `${repoRoot}/mocks/svgTransformer.js`,
};
Updated svgTransformer.js to work as both moduleNameMapper mock AND transformer:
const React = require('react');
const SvgMock = (props) => React.createElement('svg', props);
SvgMock.ReactComponent = SvgMock;
module.exports = SvgMock;
module.exports.ReactComponent = SvgMock;
module.exports.default = SvgMock;
module.exports.process = function(src, filename) {
const name = path.basename(filename, '.svg').replace(/[^a-zA-Z0-9]/g, '_');
return {
code: `
const React = require('react');
const ${name} = (props) => React.createElement('svg', props);
exports.ReactComponent = ${name};
exports.default = ${name};
module.exports = exports;
`,
};
};
Step 6: Build Output & Production package.json Generation
What Nx did automatically:
- Copied
.next,public,servertodist/apps/<appName>/ - Generated a production
package.jsonwith only runtime dependencies - Used project graph + lock file for exact dependency versions
- Hardcoded 6 required Next.js packages
What we built:
// scripts/generateProductionPackageJson.js
// Replicates @nx/next/src/executors/build/lib/update-package-json.js
const requiredPackages = [
'react', 'react-dom', 'next', 'typescript', 'sharp', 'critters'
];
// Parse yarn.lock for exact versions (like Nx's projectGraph.externalNodes)
// Scan source files for imported packages (like Nx's collectDependenciesFromFileMap)
// Collect peer dependencies (catches implicit deps like graphql for @apollo/client)
Challenges Summary
-
nextJestoverrides everything — Must export config as async function and patch after resolution -
SVG moduleNameMapper conflict —
nextJestinjects SVG handler that returns image objects, not React components -
Component library exports map — Strict
exportsfield blocks internal relative imports in Jest -
Missing non-JS transform — Nx's
@nx/react/plugins/jestwas silently handling all non-JS files -
distDir path confusion — Copied
next.config.jshad wrongdistDirfor production server -
TypeScript server output nesting — Missing
rootDirintsconfig.server.jsoncaused nested paths -
SVG viewBox stripping — SVGR's default SVGO config removes
viewBox, needs explicit override - Production package.json — Must be manually generated, Nx did this automatically via project graph
Benefits Achieved
- Simpler tooling — No Nx executors, plugins, or custom abstractions
- Transparent builds — Standard next build, tsc, npm scripts — easy to debug
- Independent deployments — Each app can be built and deployed without affecting others
- Faster CI — Turborepo only runs tasks for changed packages
- Team independence — Teams can move apps to separate repos in future
- Standard Jest — No Nx Jest preset interference
💬 Need the full scripts?
The complete scripts/generateProductionPackageJson.js implementation (~200 lines)
is available on request — just leave a comment below and I'll share it.
Same goes for the complete jest.config.js and jest.resolver.js if you'd
like to see the full working setup.
Top comments (0)