DEV Community

Kiran Mantha
Kiran Mantha

Posted on

How to create a framework-agnostic nx monorepo

Yes i'm not talking about creating an angular / react / vue monorepo. There is enough material out there including official nx articles.

What if you are a 90's kid like me who love jquery and want a monorepo?

What if you are so much fond of building your own webpack / vite based webapplications using vanilla js / lit-element etc and want a monorepo?

Nope those are not articulated..

That's why i want to bring this to you.

Necessity is mother of invention

Ask me what encouraged me to do this. I built my own framework PlumeJS and thought of creating a monorepo style code base. This is what encouraged me to explore the possibility.

The outcome? Fantastic.. and know what, the steps I followed are framework agnostic. Means you too can create your own favorite UI tech stack based nx monorepo.

Freedom can be felt if you are out of the cage.

Enough quotes and blabbering. Show me real stuff.

Yea.. this is just warm up. Now lets dive πŸ˜„

For simplicity I'm using:

  1. Jquery for interactions,
  2. Vite for bundler
  3. Tailwind (cdn) for styling
  4. serve (to run static server on dist)

Steps:

Step 1: Create Shell

NOTE: By defualt @nx/js:library has tsconfig module type as commonjs. change this to ESNext for better tree shaking.

  • Create nx workspace with typescript preset:
npx create-nx-workspace@latest <your-monorepo-name> --preset=ts
Enter fullscreen mode Exit fullscreen mode

I'm naming the monorepo as jquery-monorepo. After you run the above command, it should roughly create the below folder structure:

jquery-monorepo/
β”œβ”€β”€ nx.json
β”œβ”€β”€ package.json
└── tsconfig.base.json
Enter fullscreen mode Exit fullscreen mode
  • As we're using jquery, lets install it by:

    1. run npm i jquery -s
    2. run npm i @types/jquery serve -D
  • This will install jquery and its types for the sake of typescript.

  • Now lets create new packages folder. the updated folder structure should be as below:

jquery-monorepo/
β”œβ”€β”€ packages
β”œβ”€β”€ nx.json
β”œβ”€β”€ package.json
└── tsconfig.base.json
Enter fullscreen mode Exit fullscreen mode
  • add esModuleInterop: true to tsconfig.base.json

  • Now lets create a Shell that load our web application. Shell don't hold any logic other than including common styles and libraries.

  • To create the shell, run below commands:

npx nx generate @nx/js:library shell \
--directory=packages/shell \
--publishable \
--importPath=@jquery-monorepo/shell
Enter fullscreen mode Exit fullscreen mode

choose vitest or none for test-runners and vite as bundler. we're going to use the same options going forward.

  • This will create a shell folder under packages and update the references in nx.json and tsconfig.base.json. The updated folder structure should be:
jquery-monorepo/
β”œβ”€β”€ packages/
β”‚   └── shell
β”œβ”€β”€ nx.json
β”œβ”€β”€ package.json
└── tsconfig.base.json
Enter fullscreen mode Exit fullscreen mode

Great. But one problem. @nx/js:library create a library but not a web app. In our case, shell is created as library. So we're going to make few changes to shell to make it as web app.

  • navigate to shell and

a. create folder styles with styles.css. add the below styles:

.border-b-2 {
    border-bottom-width: 2px;
}

.tab-container [data-tab-target] {
    border-color: transparent;
}
.tab-container .active-tab {
    color: rgb(63 131 248);
    border-color: rgb(63 131 248);
}
Enter fullscreen mode Exit fullscreen mode

b. delete lib folder under src
c. add index.html under root with below content:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Jquery monorepo</title>
        <link rel="stylesheet" href="./styles/styles.css"/>
        <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body>
        <div class="container mx-auto py-8">
            <h1 class="text-3xl font-bold underline text-center mb-4">Hello world!</h1>
            <section id="tabs"></section>
        </div>
        <script type="module" src="./src/index.ts"></script>
    </body>
    </html>
Enter fullscreen mode Exit fullscreen mode

d. delete lib section under build in vite.config.ts
e. add server section to vite config. the final vite config should be like this:

    /// <reference types='vitest' />
    import { defineConfig } from 'vite';
    import dts from 'vite-plugin-dts';
    import * as path from 'path';
    import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';

    export default defineConfig({
    root: __dirname,
    cacheDir: '../../node_modules/.vite/packages/shell',

    plugins: [
        nxViteTsPaths(),
        dts({
        entryRoot: 'src',
        tsConfigFilePath: path.join(__dirname, 'tsconfig.lib.json'),
        skipDiagnostics: true,
        }),
    ],

    // Uncomment this if you are using workers.
    // worker: {
    //  plugins: [ nxViteTsPaths() ],
    // },

    // Configuration for building your library.
    // See: https://vitejs.dev/guide/build.html#library-mode
    build: {
        outDir: '../../dist/packages/shell',
        reportCompressedSize: true,
        commonjsOptions: {
        transformMixedEsModules: true,
        },
        rollupOptions: {
        // External packages that should not be bundled into your library.
        external: [],
        },
    },

    server: {
        host: true,
        port: 3001,
        open: '/'
    },

    test: {
        globals: true,
        cache: {
        dir: '../../node_modules/.vitest',
        },
        environment: 'node',
        include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],

        reporters: ['default'],
        coverage: {
        reportsDirectory: '../../coverage/packages/shell',
        provider: 'v8',
        },
    },
    });
Enter fullscreen mode Exit fullscreen mode
  • add start script to shell package.json:
// shell/package.json
{
  ...
  "scripts": {
    "start": "vite"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Update the scripts section in our monorepo level package.json:
// jquery-monorepo/package.json
{
  ...
  "scripts": {
    "nx": "nx",
    "start": "nx start shell",
    "graph": "nx graph",
    "build": "nx build shell",
    "serve": "serve -s dist/packages/shell"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Finally, navigate to our monorepo root and run yarn start. Boom the application starts running on localhost:3001

Awesome. We created our own custom web application in nx monorepo. But this is just a starting step. lets create a ui lib that have Tab component using jquery. feed the tab content from products and about libs. This implies the folder structure as below:

jquery-monorepo/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ ui/
β”‚   β”‚   └── tab component
β”‚   β”œβ”€β”€ products/
β”‚   β”‚   └── products component
β”‚   β”œβ”€β”€ about/
β”‚   β”‚   └── about component
β”‚   └── shell/
β”‚       β”œβ”€β”€ src/
β”‚       β”‚   └── index.ts
β”‚       └── index.html
β”œβ”€β”€ nx.json
β”œβ”€β”€ package.json
└── tsconfig.base.json
Enter fullscreen mode Exit fullscreen mode

This inturn implies, shell will depend on ui, products and about libs.

lets create these libs one-by-one.

Step 2: UI Lib - Tab component

  • To create the ui library, let's run below commands:
npx nx generate @nx/js:library ui \   
> --directory=packages/ui \   
> --publishable \
> --importPath=@jquery-monorepo/ui  
Enter fullscreen mode Exit fullscreen mode

Choose vitest and vite in options.

This will create the ui folder under packages. As this is a lib, no need to make any changes to vite.config.ts.

  • under src folder, delete the lib folder and create tabs folder with below structure:
ui/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ tabs/
β”‚   β”‚   β”œβ”€β”€ tabs.ts
β”‚   β”‚   └── index.ts
β”‚   └── index.ts
└── package.json
Enter fullscreen mode Exit fullscreen mode
  • Let's create a reusable tabs component as below:
// ui/src/tabs/tabs.ts
import $ from 'jquery';

function createToken(): string {
  return Math.random().toString(36).substring(2);
}

export interface TabItem {
  label: string;
  content: JQuery<HTMLElement> | string;
}

export function Tabs(tabItems: Array<TabItem>) {
  let lastActiveTabPanel: JQuery<HTMLElement>;
  const token = createToken();
  const tabId = `tab-container-${token}`;
  const container = $('<div>').attr('id', tabId).addClass('tab-container mb-4');
  const tabList = $('<ul>').addClass(
    'flex flex-wrap -mb-px text-sm font-medium text-center border-b border-gray-200'
  );
  const items = tabItems.map(({ label }, index) =>
    $('<li>').append(
      $('<button>')
        .addClass(
          `inline-block p-4 rounded-t-lg border-b-2 ${
            index === 0 ? 'active-tab' : ''
          }`
        )
        .attr('data-tab-target', `#${token}-${index}`)
        .text(label)
    )
  );
  const tabContents = tabItems.map(({ content }, index) => {
    const panel = $('<div>')
      .attr('id', `${token}-${index}`)
      .addClass(`tab-panel ${index === 0 ? '' : 'hidden'}`);
    typeof content === 'string' ? panel.html(content) : panel.append(content);
    return panel;
  });
  lastActiveTabPanel = tabContents[0];
  tabList.append(items);
  container.append(tabList);
  container.append($('<div>').attr('id', '').append(tabContents));
  container.find('[data-tab-target]').on('click', (e) => {
    container.find('[data-tab-target]').removeClass('active-tab');
    const targetTabPanelId = $(e.target).attr('data-tab-target') || '';
    $(e.target).addClass('active-tab');
    lastActiveTabPanel.addClass('hidden');
    lastActiveTabPanel = $(targetTabPanelId).toggleClass('hidden');
  });
  return container;
}

// ui/src/tabs/index.ts
export * from './tabs';

// ui/src/index.ts
export * from './tabs';
Enter fullscreen mode Exit fullscreen mode
  • Cool now in our shell, lets update the index.ts:
// shell/src/index.ts

import $ from 'jquery';
import { Tabs, TabItem } from '@jquery-monorepo/ui';

const tabItems: TabItem[] = [
  {
    id: 'tab-1',
    label: 'Tab 1',
    content: 'Tab content 1',
  },
  {
    id: 'tab-2',
    label: 'Tab 2',
    content: 'Tab content 2',
  },
];

$(() => {
  $('#tabs').append(Tabs(tabItems));
});

Enter fullscreen mode Exit fullscreen mode
  • now run yarn start on our monorepo. Boom our tabs component is live

  • With this, we created our own jquery ui library with tabs component and used that library in our shell. As you see shell is the dummy consumer.

Step 3: Products & About Libs

This step is very simple.

  • Create 2 libs with names products and about with below commands:
# products
npx nx generate @nx/js:library products \
--directory=packages/products \
--publishable \
--importPath=@jquery-monorepo/products

# about
npx nx generate @nx/js:library about \
--directory=packages/about \
--publishable \
--importPath=@jquery-monorepo/about
Enter fullscreen mode Exit fullscreen mode
  • delete lib folder in those 2 above packages and update their index.ts files as below:
// about/src/index.ts

import $ from 'jquery';

export function About() {
    const aboutContainer = $('<div>').html('About Container');
    return aboutContainer;
}

// products/src/index.ts
import $ from 'jquery';

export function Products() {
    const productsContainer = $('<div>').html('Products Container');
    return productsContainer;
}
Enter fullscreen mode Exit fullscreen mode
  • Now in shell:
// shell/src/index.ts
import $ from 'jquery';
import { Tabs, TabItem } from '@jquery-monorepo/ui';
import { About } from '@jquery-monorepo/about';
import { Products } from '@jquery-monorepo/products';

const tabItems: TabItem[] = [
  {
    label: 'About',
    content: About(),
  },
  {
    label: 'Products',
    content: Products(),
  },
];

$(() => {
  $('#tabs').append(Tabs(tabItems));
});

Enter fullscreen mode Exit fullscreen mode

Excellent. shell is now able to display tabs component from ui populated by about and product libs.

For instance, if you run yarn graph on monorepo this is what you see:

nx graph showcasing shell, products, about and ui

But we need to ensure when we build this monorepo and deploy, it should work.

How to check this?

If you remember, we have build and serve scripts in our monorepo package.json.

Run yarn build && yarn serve and navigate to localhost:3000. Marvelous, our distribution package worked πŸ˜„

NOTE: If the build is success and you see a message Cannot call a namespace ("$"). then change the module type in all tsconfig.json files to ESNext and set esModuleInterop to true in tsconfig.base.json

Qudos if you make this far. As you see we created a nx monorepo for our jquery project. Now in place of JQuery, use lit-element or web-components or anything. The process is same.

You can fine tune the folder structure by splitting packages into apps and libs where apps hold multiple shell folders and libs have all the common logic.

Checkout the source code here.

Post your comments after experimenting or facing any issues.

Thanks for reading and see you next time,
Happy coding,
Kiran πŸ‘‹ πŸ‘‹

Top comments (0)