DEV Community

Cover image for Beyond the Docs: The Hidden Challenges of Nx to Turborepo Migration
Harsimran singh Virk
Harsimran singh Virk

Posted on

Beyond the Docs: The Hidden Challenges of Nx to Turborepo Migration

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:

  1. Single repository complexity: All apps lived in one repo, making it hard to manage independent deployments.
  2. Tight Coupling: A change in a shared library triggered unnecessary builds across the entire suite.
  3. Nx overhead: Nx added significant tooling complexity, custom executors, and abstraction layers that were hard to debug.
  4. Team autonomy: Different teams couldn't work independently on their apps.

Why Turborepo

  1. Simpler mental model: Turborepo is a task runner, not a framework — it does less but does it transparently
  2. Better polyrepo support: Each app can eventually live in its own repo while sharing Turborepo pipeline config
  3. Faster builds: Turborepo's caching is simpler to configure and works well out of the box
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Steps taken:

  1. Add turbo.json at root with task pipeline definitions
  2. Ensure each app has its own package.json with a unique name field
  3. Update root package.json workspaces 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
    }
  }
} 
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Challenges:

  • withNx was handling SVGR configuration automatically
  • withNx was setting distDir based on Nx's outputPath in project.json
  • composePlugins was an Nx-specific utility
  • withNx was 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);
Enter fullscreen mode Exit fullscreen mode

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:build executor automatically copied build output to dist/apps/<appName>/
  • @nx/js:tsc executor compiled the custom server
  • update-package-json step auto-generated a production package.json
  • modifyPackageJson.js patched the start script

tsconfig.server.json fix:

"outDir": "../../dist/apps/<appName>",
"rootDir": ".",   // Critical: prevents nested path duplication
Enter fullscreen mode Exit fullscreen mode

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'] }],
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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, '../..');
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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`,
};
Enter fullscreen mode Exit fullscreen mode

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;
    `,
  };
};
Enter fullscreen mode Exit fullscreen mode

Step 6: Build Output & Production package.json Generation

What Nx did automatically:

  • Copied .next, public, server to dist/apps/<appName>/
  • Generated a production package.json with 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)
Enter fullscreen mode Exit fullscreen mode

Challenges Summary

  1. nextJest overrides everything — Must export config as async function and patch after resolution
  2. SVG moduleNameMapper conflict — nextJest injects SVG handler that returns image objects, not React components
  3. Component library exports map — Strict exports field blocks internal relative imports in Jest
  4. Missing non-JS transform — Nx's @nx/react/plugins/jest was silently handling all non-JS files
  5. distDir path confusion — Copied next.config.js had wrong distDir for production server
  6. TypeScript server output nesting — Missing rootDir in tsconfig.server.json caused nested paths
  7. SVG viewBox stripping — SVGR's default SVGO config removes viewBox, needs explicit override
  8. 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)