This is part three of a new series on monorepos. By the end of the series, you’ll have the tools you need to adopt monorepo setups in your organization.

Rounding out our series on monorepos, we take a look at an old friend, but a newcomer to the monorepo game, npm. Npm has long been the de-facto solution for managing dependencies, and it only makes sense that, with the release of npm 7.0, we finally have a built-in solution for creating a monorepo without relying on external tools. Compared to other solutions, however, npm workspaces lack a few features and still have some rough edges. While it is possible to build something with it, for simplicity, I’d suggest looking at Lerna as an alternative. With that being said, let’s look at how we can configure an npm workspace to work with Ionic and Vue.


To set the scene, what we’re going to build is an Ionic Vue app and a second project that contains a Vue hook. The hook is borrowed from the vue-composable project.

Let’s get started by first creating our base directory and initializing both a package.json and an ionic.config.json. For the package.json, run:

mkdir vue-monorepo
cd vue-monorepo
npm init -y

From here, we can also create a base Ionic project with the ionic init command.

ionic init --multi-app

We can also create a directory that will hold all the packages. For this, a directory called packages will do, but the name can be whatever you’d like. packages is just a common convention that people have settled around.

mkdir packages
cd packages

With this done, we’re going to create a single Ionic Vue project and a minimal utility package.

mkdir utils
ionic start client-app tabs --type vue --no-deps --no-git

Currently, even if you pass the --no-deps flag, dependencies will be installed when Capacitor is set up. Just cd client-app and delete the node_modules folder from the project.

Setting up the Utils

For our utils package, we’re going to do a bit more manual work to set up a minimal package of hooks for our Vue project.

cd packages/utils
npm init -y
mkdir src
touch tsconfig.json

Open your package.json and paste the following:

  "name": "@client/hooks",
  "version": "0.1.0",
  "private": true,
  "main": "dist/index.js",
  "module": "dist/index.js",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "watch": "tsc -p tsconfig.json --watch"
  "dependencies": {
    "vue": "^3.0.0"
  "files": ["dist/"],
  "devDependencies": {
    "typescript": "~4.1.5"

Then, open your tsconfig.json and paste the following:

  "compilerOptions": {
    "target": "ES5",
    "outDir": "dist",
    "module": "CommonJS",
    "strict": true,
    "importHelpers": true,
    "moduleResolution": "node",
    "skipLibCheck": true,
    "esModuleInterop": false,
    "declaration": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "lib": ["esnext", "dom", "dom.iterable"]
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]

From here, we can make a file, src/index.ts, and paste the following code.

/* eslint-disable */
import { ref, Ref } from 'vue';

// useOnline composable hook.
// Adapted from

const PASSIVE_EV: AddEventListenerOptions = { passive: true };
let online: Ref<boolean> | undefined = undefined;
export function useOnline() {
  const supported = 'onLine' in navigator;
  if (!supported) {
    online = ref(false);

  if (!online) {
    online = ref(navigator.onLine);
      () => (online!.value = false),
    window.addEventListener('online', () => (online!.value = true), PASSIVE_EV);

  return { supported, online };

Now we can leave the utils directory and get back to the root project.

Setting up the Workspace

With the initial code created, we can now set up the workspace. For npm, workspaces are just an entry in the root package.json. Since all of our packages are in the packages directory, we can add the following to the root package.json.

  "name": "ionic-vue-npm-workspaces",
  "version": "1.0.0",
  "description": "",
  "scripts": {...},
  "license": "MIT",

  "workspaces": [


The workspaces entry allows us to declare what packages are available from this top level. Since we want to expose all packages in the packages directory, we can use the packages/* to get all of them.

With this completed, run npm install from the top level. With our workspace set up to include all the sub-packages, our install will actually install all dependencies used in both projects in one top-level node_modules directory. This means we can have better control over what dependencies we are using in which project and unifies all duplicated dependencies to one version.

With the dependencies installed, how do we go about actually building our sub-packages? This can be done by calling the script we want to run, followed by the --workspace=<package-name>. If we want to build the utils directory, we use the name entry from the package.json (@client/hooks) as the value for the workspace. So our final command looks like this:

npm run build --workspace=@client/hooks

The same logic would be applied if we want to build/serve our app: we pick the script we want to run and pass the name to the workspace.

Including a Package

So far, we have our packages set up and building, but we’re not making use of them, which kind of defeats the point of having a monorepo. So how can we consume our utils packages in our main app? To do this, we’ll reference the package in our app.

In the client-app project, let’s open our package.json and add a line to our dependencies for @client/hooks:

  "dependencies": {
    "@capacitor/core": "3.0.0-rc.1",
    "@client/hooks": "0.1.0",
    "@ionic/vue": "^5.4.0",
    "@ionic/vue-router": "^5.4.0",
    "core-js": "^3.6.5",
    "vue": "^3.0.0-0",
    "vue-router": "^4.0.0-0"

Then we can add a reference to @client/hooks in our project in the client-app/src/views/Tab1.vue component.

        <ion-title>Tab 1</ion-title>
    <ion-content :fullscreen="true">
      <ion-header collapse="condense">
          <ion-title size="large">Tab 1</ion-title>
        <h1>Is the App online?</h1>
        <p>{{ online }}</p>
      <ExploreContainer name="Tab 1 page" />

<script lang="ts">
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue';
import ExploreContainer from '@/components/ExploreContainer.vue';

import { useOnline } from '@client/hooks';

export default  {
  name: 'Tab1',
  components: { ExploreContainer, IonHeader, IonToolbar, IonTitle, IonContent, IonPage },
    setup() {
    const { online } = useOnline();
    return { online };


We can save and go back to the terminal, and from the root, run:

npm install
npm run serve --workspace=client-app

When we open the browser to localhost:8080, our app should include the code from our second package.

Parting Thoughts

Of all of the options available, npm workspaces include the fewest features when compared to yarn/Lerna or nx. But that could be beneficial to you and your team if you want to have more control over how your monorepos work. This could be perfect for a team that likes to tinker with things, or wants to assemble their own monorepo infrastructure. Either way, it’s great to see npm enter the monorepo game, and we can’t wait to see how workspaces evolve over time.

Signup for the Ionic Newsletter to get the latest news and updates!