A monorepo’s shared Cypress configuration can actually be less complex than a single-project setup, provided you understand how it resolves relative paths.

Let’s see it in action. Imagine this monorepo structure:

monorepo-root/
├── packages/
│   ├── ui-components/
│   │   ├── src/
│   │   └── cypress/
│   │       └── component/
│   │           └── ui-components.cy.js
│   ├── auth-service/
│   │   ├── src/
│   │   └── cypress/
│   │       └── e2e/
│   │           └── auth-service.cy.js
│   └── app-frontend/
│       ├── src/
│       └── cypress/
│           └── e2e/
│               └── app-frontend.cy.js
├── cypress/
│   ├── component/
│   │   └── cypress.config.js
│   ├── e2e/
│   │   └── cypress.config.js
│   └── plugins/
│       └── index.js
└── package.json

Here, we have a cypress directory at the root for shared configurations and plugins, and individual cypress directories within each package for package-specific tests.

The core problem Cypress faces in a monorepo is understanding what baseUrl and supportFile refer to when the configuration is hoisted or shared. Cypress typically resolves these paths relative to the cypress.config.js file. In a monorepo, this can lead to "file not found" errors if the paths aren’t correctly structured.

The Shared cypress.config.js

Let’s look at a shared cypress/e2e/cypress.config.js at the root:

// cypress/e2e/cypress.config.js
const { defineConfig } = require('cypress');
const webpack = require('@cypress/webpack-dev-server');

module.exports = defineConfig({
  // This is crucial: 'e2e' is the type of test
  e2e: {
    // The baseUrl is often shared across many frontend apps
    // We'll configure this dynamically later, but a default is good for clarity
    baseUrl: 'http://localhost:3000',
    specPattern: 'packages/**/cypress/e2e/**/*.cy.js', // <-- Key for finding specs
    // The support file can also be shared
    supportFile: 'cypress/support/e2e.js', // <-- Key for shared support
    devServer: {
      framework: 'create-react-app', // Or 'next', 'vue', etc.
      bundler: 'webpack',
      webpackConfig: webpack({}, {}), // You'll likely need to merge your actual webpack config here
    },
    setupNodeEvents(on, config) {
      // `on` is used to register event listeners
      // `config` is the resolved cypress config
      require('./cypress/plugins/index')(on, config); // Ensure plugins are loaded
      return config;
    },
  },
});

And a similar cypress/component/cypress.config.js:

// cypress/component/cypress.config.js
const { defineConfig } = require('cypress');
const webpack = require('@cypress/webpack-dev-server');

module.exports = defineConfig({
  component: {
    devServer: {
      framework: 'react',
      bundler: 'webpack',
      // You might need to integrate your component build process here.
      // For many setups, this involves merging your existing webpack config.
      // Example:
      // webpackConfig: require('../../../webpack.config.js') // Adjust path as needed
    },
    specPattern: 'packages/**/cypress/component/**/*.cy.js', // <-- Key for finding component specs
    supportFile: 'cypress/support/component.js', // <-- Key for shared component support
    // No baseUrl needed for component testing usually
  },
});

The magic happens in specPattern. By using packages/**/cypress/e2e/**/*.cy.js, we tell Cypress to look for spec files anywhere within the packages directory that match the cypress/e2e pattern. This allows each package to have its own spec files without needing a separate root-level cypress.config.js for each.

Package-Specific Overrides and Configurations

What if one package needs a different baseUrl or a specific supportFile? You can use Cypress’s configuration merging. When you run Cypress, you can specify a config file path using the --config-file flag.

For instance, to run tests for auth-service with its own config:

npx cypress run --config-file packages/auth-service/cypress.config.js

Or, if you want to extend the root config:

// packages/auth-service/cypress.config.js
const { defineConfig } = require('cypress');
const dotenv = require('dotenv');

// Load environment variables for this specific package
dotenv.config({ path: './packages/auth-service/.env' });

// Import the base config to extend it
const baseConfig = require('../../cypress/e2e/cypress.config');

module.exports = defineConfig({
  // Merge the base configuration
  e2e: {
    ...baseConfig.e2e,
    // Override specific settings for this package
    baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost:4000', // Example: different local port
    // You could also override specPattern if needed, but it's usually better to keep it broad
    // specPattern: 'packages/auth-service/cypress/e2e/**/*.cy.js',
    supportFile: 'packages/auth-service/cypress/support/e2e.js', // Example: package-specific support
    setupNodeEvents(on, config) {
      // Call the base setupNodeEvents to ensure shared plugins are loaded
      const extendedConfig = baseConfig.e2e.setupNodeEvents(on, config);
      // Add package-specific plugins if needed
      // require('./cypress/plugins/auth-plugins')(on, extendedConfig);
      return extendedConfig;
    },
  },
});

In this scenario, packages/auth-service/cypress.config.js inherits all settings from ../../cypress/e2e/cypress.config.js and then overrides baseUrl and supportFile. The setupNodeEvents also calls the parent’s setupNodeEvents to ensure shared plugins are still executed.

The cypress/plugins/index.js

This is where you can set up shared Node.js event listeners. For example, to load environment variables for all tests:

// cypress/plugins/index.js
const dotenv = require('dotenv');

module.exports = (on, config) => {
  // Load .env files for the root project or specific package
  // This example loads from the root, but you can make it dynamic
  dotenv.config();

  // You can also configure plugins here, like task handlers
  on('task', {
    // Example: a task to interact with a database
    queryDb: async (query) => {
      // Implement your database query logic here
      return 'query result';
    },
  });

  // If you're using a framework like Next.js, you might need to configure webpack here
  // Example for Next.js (adjust path as needed):
  // if (config.testingType === 'e2e') {
  //   require('@cypress/next-dev-server').devServer(config)
  // }

  // Always return the config object
  return config;
};

The key is that setupNodeEvents in your cypress.config.js files should call this shared require('./cypress/plugins/index')(on, config).

The most counterintuitive part of monorepo Cypress configuration is often how baseUrl is resolved. If you set baseUrl in your root cypress.config.js and then run a test from a package that has its own cypress.config.js which doesn’t explicitly override baseUrl, Cypress will still use the baseUrl from the root config. This behavior is due to Cypress’s configuration merging logic, where the e2e or component object is merged, and properties within that object are applied. If a property isn’t redefined in a more specific config file, the one from the broader config file takes precedence.

This setup allows you to have a single source of truth for many configurations while still offering the flexibility for individual packages to diverge when necessary.

The next thing you’ll likely encounter is configuring a monorepo-aware webpack setup for your devServer to correctly bundle your shared and package-specific code.

Want structured learning?

Take the full Cypress course →