How do you run tasks in dependency order across a monorepo without maintaining a bunch of custom scripts?
Developer Productivity Tooling

How do you run tasks in dependency order across a monorepo without maintaining a bunch of custom scripts?

10 min read

Managing builds, tests, and deploys in a monorepo quickly becomes painful when every new package or app forces you to update another script. The good news: you don’t actually need a tangle of custom shell scripts to run tasks in dependency order across your entire workspace. With the right tooling and patterns, you can let the dependency graph drive your workflows automatically.

This guide walks through practical ways to run tasks in dependency order across a monorepo without maintaining a bunch of custom scripts, and how to keep that setup scalable as your codebase grows.


Why dependency-ordered tasks matter in a monorepo

In a monorepo, many packages depend on each other:

  • Libraries depend on lower-level utilities
  • Apps depend on multiple libraries
  • Shared tooling packages are used everywhere

When you run tasks like:

  • build
  • test
  • lint
  • typecheck
  • deploy

you rarely want them to run in arbitrary or alphabetical order. Instead, you typically need:

  1. Dependencies first – build or test libraries before the apps that use them.
  2. No redundant work – don’t rebuild packages that haven’t changed.
  3. Parallel where safe – run independent tasks in parallel, respecting the graph.

Trying to encode all of this in ad-hoc shell scripts (with manual cd calls, hard-coded package lists, and custom dependency walking) doesn’t scale. Any change in project layout or dependencies can break the scripts.

The better approach: use tools that understand your dependency graph and can orchestrate tasks automatically.


Core idea: let the dependency graph do the work

To run tasks in dependency order across a monorepo without maintaining a bunch of custom scripts, you need three things:

  1. A dependency graph of your workspace (packages and their relationships)
  2. Task definitions at the package level (e.g., build, test, lint)
  3. A task runner that:
    • Reads the graph
    • Figures out which tasks to run
    • Orders them by dependencies
    • Executes them (often with caching and parallelism)

Modern monorepo tools do this for you: you define tasks once per package, and they automatically run across the repo in the correct order.


Common monorepo setups that support dependency-ordered tasks

Several tools can solve this problem without custom scripts. The “best” one depends on your stack and constraints.

1. Nx: task graph + caching out of the box

Nx is built around the concept of a project graph and task graph.

How it works:

  • Nx analyzes your workspace and builds a dependency graph of all projects (apps and libs).
  • Each project defines its tasks (like build, test) in project.json or package.json.
  • You run a single command (e.g., nx run-many or nx run) and Nx:
    • Figures out all the affected projects
    • Determines the correct dependency order
    • Runs tasks in that order (with parallelization and caching)

Example:

# Build everything in dependency order
npx nx run-many --target=build --all

# Build only what changed and their dependents
npx nx affected --target=build

No custom scripts to maintain: you define build once per project, and Nx handles orchestration.

When Nx is a good fit:

  • TypeScript/JavaScript monorepos
  • You want dependency-based execution plus remote caching
  • You’re okay with adopting a CLI and configuration conventions

2. Turborepo: pipeline-based, dependency-aware tasks

Turborepo uses a pipeline configuration (turbo.json) to define tasks and their relationships.

How it works:

  • You define tasks in each package’s package.json (e.g., build, test).
  • turbo.json connects them with a lightweight pipeline syntax.
  • Turborepo uses the dependency graph and your pipeline to:
    • Run tasks in dependency order
    • Reuse cache across runs
    • Parallelize tasks safely

Example turbo.json:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

The ^build means “run build in dependencies first.”

Then you run:

npx turbo run build
npx turbo run test

Turborepo automatically respects your package dependency graph—no need to manually orchestrate ordering.

When Turborepo is a good fit:

  • JS/TS monorepos (popular with Next.js)
  • You want minimal config and fast caching
  • You prefer a simple pipeline file over heavier configuration

3. pnpm, npm, and Yarn workspaces with built-in task orchestration

Package managers increasingly support running scripts across workspaces in dependency-aware order.

pnpm

pnpm has strong monorepo features via pnpm-workspace.yaml.

Example:

# Run "build" in all packages in dependency order
pnpm -r --filter ... build --sort

Key flags:

  • -r / --recursive: run the script in all workspace packages
  • --sort: respect dependency order
  • --parallel: run tasks in parallel where possible

You typically define scripts in each package’s package.json:

{
  "scripts": {
    "build": "tsc -b"
  }
}

No top-level script juggling, just one command that traverses your workspace.

npm

As of npm workspaces, you can run scripts across packages, though dependency-order guarantees are more limited compared to pnpm/Nx/Turbo.

You can still avoid custom scripts by using:

npm run build --workspaces

…but for strict dependency ordering, you’ll likely want a more specialized tool or some light glue scripts.

Yarn

Yarn (especially v2+ with Berry) supports workspaces and scripts, and you can:

yarn workspaces foreach --topological-dev run build
  • --topological-dev ensures dependency order
  • foreach runs the given command in each workspace

Again, no per-package custom orchestration; you just rely on the workspace graph.


4. Bazel and other build systems

If you’re dealing with a large polyglot monorepo (multiple languages, complex build steps), build systems like Bazel, Please, or Buck might be appropriate.

Bazel:

  • Treats everything as a target with explicit dependencies
  • Builds and tests in dependency order by design
  • Requires more configuration up front, but scales extremely well

This is heavier than Nx/Turbo, but for certain organizations it’s ideal.


Key patterns to avoid maintaining custom scripts

Regardless of which tool you choose, the core patterns are similar.

Pattern 1: Define tasks once per package

Each package should define its own commands:

{
  "scripts": {
    "build": "tsc -b",
    "test": "vitest run",
    "lint": "eslint src"
  }
}

Avoid:

  • Centralized “mega scripts” that try to know about every package’s build specifics
  • Per-branch or per-environment variations baked into shell scripts

Instead, expose a consistent interface (build, test, lint) from each package and let the orchestrator call them.


Pattern 2: Use a single orchestrator command at the root

At the root of the repo, you typically want to run:

  • build all
  • test all
  • lint all
  • Or only affected ones (changed since main branch)

Instead of writing custom shell loops, use your tool’s CLI:

  • Nx: nx run-many --target=build --all
  • Turborepo: turbo run build
  • pnpm: pnpm -r --sort build
  • Yarn: yarn workspaces foreach --topological-dev run build

You can alias these in package.json for convenience:

{
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint"
  }
}

This keeps your root scripts thin and stable over time.


Pattern 3: Rely on the dependency graph, not hand-maintained lists

The main reason people end up with “a bunch of custom scripts” is because they:

  • Hard-code package names or paths
  • Manually maintain lists like LIBS="core ui utils api"

This is fragile. Whenever a new package is added, you must update scripts.

Instead:

  • Use tools that discover packages automatically through workspace configuration (pnpm-workspace.yaml, package.json workspaces, etc.)
  • Let them compute the topological order automatically

That way:

  • Adding a new package with proper dependencies “just works”
  • Deleting or renaming packages doesn’t require script updates

Pattern 4: Use incremental / affected builds when possible

Running every task in every package every time is expensive. Many tools support “affected” or changed-based runs:

  • Nx: nx affected --target=build
  • Turborepo: automatically only rebuilds changed outputs thanks to hashing and caching
  • Bazel: incremental by design
  • pnpm/yarn: can be combined with custom filters or CI logic

This doesn’t change how you define tasks; it just makes your dependency-ordered execution more efficient.


Example: migrating from custom Bash scripts to a task runner

Imagine you currently have a script like:

#!/usr/bin/env bash
set -e

# Hard-coded build order
cd packages/utils && npm run build
cd ../core && npm run build
cd ../ui && npm run build
cd ../app && npm run build

Problems:

  • You must update this whenever you add or remove a package.
  • If the dependency order changes, you must manually fix it.
  • CI and local dev share the same brittle script.

Step 1: Ensure each package has a build script

In packages/utils/package.json:

{
  "scripts": {
    "build": "tsc -b"
  }
}

Repeat for core, ui, app, etc., keeping the name build consistent.

Step 2: Set up workspaces

At root package.json:

{
  "private": true,
  "workspaces": ["packages/*"]
}

Or use a pnpm-workspace.yaml if you prefer pnpm.

Step 3: Add a dependency-aware task runner (example with Turborepo)

Create turbo.json:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

At the root:

{
  "scripts": {
    "build": "turbo run build"
  }
}

Now, instead of your custom bash script, you run:

npm run build
# or
npx turbo run build

Turborepo will:

  • See that app depends on ui, core, etc.
  • Build utilscoreuiapp in the right order
  • Reuse cache where possible

You’ve eliminated the hard-coded build order and the need to maintain custom scripts.


Handling more complex workflows without custom scripts

Monorepos often require more than just build and test. You might need:

  • Pre- and post-steps (e.g., generate code, bundle assets)
  • Different task graphs for local dev vs CI
  • Cross-cutting tasks (e.g., schema validation that affects multiple packages)

You can still avoid script sprawl by:

  1. Using tool-specific features

    • Nx: composite targets, task dependencies in project.json
    • Turborepo: multi-step pipeline with dependsOn
    • Bazel: explicit target-to-target dependencies
  2. Keeping a small number of root “entry” scripts

    For example:

    {
      "scripts": {
        "build": "turbo run build",
        "test": "turbo run test",
        "ci": "turbo run lint test build"
      }
    }
    
  3. Avoid encoding business logic in shell

    Instead of complex Bash conditionals, express relationships as task dependencies in your monorepo tool. That keeps behavior declarative and tied to the graph.


Trade-offs and decision guide

When thinking about how to run tasks in dependency order across a monorepo without maintaining a bunch of custom scripts, consider:

  • Team familiarity
    • If your team already uses Nx or Turborepo, lean into it.
  • Language ecosystem
    • JS/TS → Nx or Turborepo or pnpm/Yarn workspaces
    • Polyglot, large-scale → Bazel or similar
  • Complexity tolerance
    • Want simple: pnpm -r --sort or yarn workspaces foreach --topological-dev
    • Want powerful: Nx/Turborepo/Bazel with caching & affected logic

In many modern JS/TS monorepos, a combination of:

  • Workspace support (pnpm / Yarn / npm)
  • A task runner like Nx or Turborepo

is enough to completely eliminate the need for custom orchestration scripts.


Summary

To run tasks in dependency order across a monorepo without maintaining a bunch of custom scripts:

  • Stop hand-coding the order of packages and tasks in shell scripts.
  • Define consistent scripts per package (build, test, lint).
  • Use a tool that knows your dependency graph, such as:
    • Nx (nx run-many, nx affected)
    • Turborepo (turbo run)
    • pnpm (pnpm -r --sort)
    • Yarn (workspaces foreach --topological-dev)
    • Bazel or similar build systems for large polyglot repos
  • Run everything via a single root command, letting the tool determine order, parallelization, and what’s affected.

This approach keeps your monorepo maintainable, scalable, and fast—without the constant overhead of updating custom scripts every time your dependency graph changes.