Building Vue Apps with the Nx Standalone Projects Setup
In this tutorial you'll learn how to use Vue with Nx in a "standalone" (non-monorepo) setup. Not to be confused with the "Vue Standalone API", a standalone project in Nx is a non-monorepo setup where you have a single application at the root level. This setup is very similar to what the Vue CLI gives you.
What are you going to learn?
- how to create a new standalone (single-project) Nx workspace setup for Vue
- how to run a single task (i.e. serve your app) or run multiple tasks in parallel
- how to leverage code generators to scaffold components
- how to modularize your codebase and impose architectural constraints for better maintainability
Note, while you could easily use Nx together with your manually set up Vue application, we're going to use the @nx/vue
plugin for this tutorial which provides some nice enhancements when working with Vue. Visit our "Why Nx" page to learn more about plugins and what role they play in the Nx architecture.
Warm Up
Here's the source code of the final result for this tutorial.
Example repository/nrwl/nx-recipes/tree/main/vue-standalone
Creating a new Vue App
Create a new Vue application with the following command:
~❯
npx create-nx-workspace@latest myvueapp --preset=vue-standalone
1 > NX Let's create a new workspace [https://nx.dev/getting-started/intro]
2
3✔ Test runner to use for end to end (E2E) tests · cypress
4✔ Default stylesheet format · css
5✔ Set up CI with caching, distribution and test deflaking · github
6
7 > NX Creating your v17.0.0 workspace.
8
9 To make sure the command works reliably in all environments, and that the preset is applied correctly,
10 Nx will run "npm install" several times. Please wait.
11
You can also choose Playwright for your e2e tests or a different stylesheet format. In this tutorial we're going to use Cypress and css. The above command generates the following structure:
1└─ myvueapp
2 ├─ .vscode
3 │ └─ extensions.json
4 ├─ e2e
5 │ ├─ ...
6 │ ├─ project.json
7 │ ├─ src
8 │ │ ├─ e2e
9 │ │ │ └─ app.cy.ts
10 │ │ ├─ ...
11 │ └─ tsconfig.json
12 ├─ src
13 │ ├─ app
14 │ │ ├─ App.spec.ts
15 │ │ ├─ App.vue
16 │ │ └─ NxWelcome.vue
17 │ ├─ main.ts
18 │ └─ styles.css
19 ├─ index.html
20 ├─ nx.json
21 ├─ package.json
22 ├─ project.json
23 ├─ README.md
24 ├─ tsconfig.app.json
25 ├─ tsconfig.base.json
26 ├─ tsconfig.json
27 ├─ tsconfig.spec.json
28 └─ vite.config.ts
29
The setup includes..
- a new Vue application at the root of the Nx workspace (
src
) - a Cypress based set of e2e tests (
e2e/
) - Prettier preconfigured
- ESLint preconfigured
- Vitest preconfigured
Let me explain a couple of things that might be new to you.
File | Description |
---|---|
nx.json | This is where we fine-tune how Nx works. We define what cacheable operations there are, and configure our task pipeline. More on that soon. |
project.json | This file is where you can modify the inferred tasks for the myvueapp project. More about this later. |
Serving the App
The most common tasks are already defined in the package.json
file:
1{
2 "name": "myvueapp",
3 "scripts": {
4 "start": "nx serve",
5 "build": "nx build",
6 "test": "nx test"
7 }
8 ...
9}
10
To serve your new Vue application, just run: npm start
. Alternatively you can directly use Nx by using
❯
nx serve
Your application should be served at http://localhost:4200.
Nx uses the following syntax to run tasks:
Inferred Tasks
Nx identifies available tasks for your project from tooling configuration files, package.json
scripts and the targets defined in project.json
. To view the tasks that Nx has detected, look in the Nx Console project detail view or run:
❯
nx show project myvueapp --web
myvueapp
Root: .
Type: Application
Targets
build
vite build
Cacheable
If you expand the build
task, you can see that it was created by the @nx/vite
plugin by analyzing your vite.config.ts
file. Notice the outputs are defined as {projectRoot}/dist/myvueapp
. This value is being read from the build.outDir
defined in your vite.config.ts
file. Let's change that value in your vite.config.ts
file:
1export default defineConfig({
2 // ...
3 build: {
4 outDir: './build/myvueapp',
5 // ...
6 },
7});
8
Now if you look at the project details view, the outputs for the build target will say {projectRoot}/build/myvueapp
. This feature ensures that Nx will always cache the correct files.
You can also override the settings for inferred tasks by modifying the targetDefaults
in nx.json
or setting a value in your project.json
file. Nx will merge the values from the inferred tasks with the values you define in targetDefaults
and in your specific project's configuration.
Testing and Linting - Running Multiple Tasks
Our current setup not only has targets for serving and building the Vue application, but also has targets for unit testing, e2e testing and linting. We can use the same syntax as before to run these tasks:
1nx test # runs tests using Jest
2nx lint # runs linting with ESLint
3nx e2e e2e # runs e2e tests with Cypress
4
More conveniently, we can also run them in parallel using the following syntax:
myvueapp❯
nx run-many -t test lint e2e
1
2 ✔ nx run e2e:lint (1s)
3 ✔ nx run myvueapp:lint (1s)
4 ✔ nx run myvueapp:test (2s)
5 ✔ nx run e2e:e2e (6s)
6
7 ——————————————————————————————————————————————————————
8
9 > NX Successfully ran targets test, lint, e2e for 2 projects (8s)
10
Caching
One thing to highlight is that Nx is able to cache the tasks you run.
Note that all of these targets are automatically cached by Nx. If you re-run a single one or all of them again, you'll see that the task completes immediately. In addition, (as can be seen in the output example below) there will be a note that a matching cache result was found and therefore the task was not run again.
myvueapp❯
nx run-many -t test lint e2e
1
2 ✔ nx run myvueapp:lint [existing outputs match the cache, left as is]
3 ✔ nx run e2e:lint [existing outputs match the cache, left as is]
4 ✔ nx run myvueapp:test [existing outputs match the cache, left as is]
5 ✔ nx run e2e:e2e [existing outputs match the cache, left as is]
6
7 ———————————————————————————————————————————————————————
8
9 > NX Successfully ran targets test, lint, e2e for 2 projects (143ms)
10
11 Nx read the output from the cache instead of running the command for 4 out of 4 tasks.
12
Not all tasks might be cacheable though. You can mark all targets of a certain type as cacheable by setting cache
to true
in the targetDefaults
of the nx.json
file. You can also learn more about how caching works.
Nx Plugins? Why?
One thing you might be curious about is the inferred tasks. You may wonder why we are detecting tasks from your tooling configuration instead of directly defining them in package.json
scripts or in the project.json
file.
Nx understands and supports both approaches, allowing you to define tasks in your package.json
and project.json
files or have Nx plugins automatically detect them. The inferred tasks give you the benefit of automatically setting the Nx cache settings for you based on your tooling configuration. In this tutorial, we take advantage of those inferred tasks to demonstrate the full value of Nx plugins.
So, what are Nx Plugins? Nx Plugins are optional packages that extend the capabilities of Nx, catering to various specific technologies. For instance, we have plugins tailored to Vue (e.g., @nx/vue
), Vite (@nx/vite
), Cypress (@nx/cypress
), and more. These plugins offer additional features, making your development experience more efficient and enjoyable when working with specific tech stacks.
Visit our "Why Nx" page for more details.
Creating New Components
You can just create new Vue components as you normally would. However, Nx plugins usually also ship generators. They allow you to easily scaffold code, configuration or entire projects. To see what capabilities the @nx/vue
plugin ships, run the following command and inspect the output:
myvueapp❯
npx nx list @nx/vue
1
2> NX Capabilities in @nx/vue:
3
4 GENERATORS
5
6 init : Initialize the `@nx/vue` plugin.
7 application : Create a Vue application.
8 library : Create a Vue library.
9 component : Create a Vue component.
10 setup-tailwind : Set up Tailwind configuration for a project.
11 storybook-configuration : Set up storybook for a Vue app or library.
12 stories : Create stories for all components declared in an app or library.
13
If you prefer a more integrated experience, you can install the "Nx Console" extension for your code editor. It has support for VSCode, IntelliJ and ships a LSP for Vim. Nx Console provides autocompletion support in Nx configuration files and has UIs for browsing and running generators.
More info can be found in the integrate with editors article.
Run the following command to generate a new "hello-world" component. Note how we append --dry-run
to first check the output.
myvueapp❯
npx nx g @nx/vue:component hello-world --no-export --unit-test-runner=vitest --directory=src/components --dry-run
1> NX Generating @nx/vue:component
2
3CREATE src/components/hello-world.spec.ts
4CREATE src/components/hello-world.vue
5
6NOTE: The "dryRun" flag means no changes were made.
7
As you can see it generates a new component in the src/components/
folder. If you want to actually run the generator, remove the --dry-run
flag.
1<script setup lang="ts">
2// defineProps<{}>();
3</script>
4
5<template>
6 <p>Welcome to HelloWorld!</p>
7</template>
8
9<style scoped></style>
10
Building the App for Deployment
If you're ready and want to ship your application, you can build it using
myvueapp❯
npx nx build
1> nx run myvueapp:build:production
2
3vite v4.3.9 building for production...
4✓ 15 modules transformed.
5dist/myvueapp/index.html 0.43 kB │ gzip: 0.29 kB
6dist/myvueapp/assets/index-a0201bbf.css 7.90 kB │ gzip: 1.78 kB
7dist/myvueapp/assets/index-46a11b5f.js 62.39 kB │ gzip: 24.35 kB
8✓ built in 502ms
9
10 —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
11
12 > NX Successfully ran target build for project myvueapp (957ms)
13
All the required files will be placed in the dist/myvueapp
folder and can be deployed to your favorite hosting provider.
You're ready to go!
In the previous sections you learned about the basics of using Nx, running tasks and navigating an Nx workspace. You're ready to ship features now!
But there's more to learn. You have two possibilities here:
- Jump to the next steps section to find where to go from here or
- keep reading and learn some more about what makes Nx unique when working with Vue.
Modularizing your Vue App with Local Libraries
When you develop your Vue application, usually all your logic sits in the app
folder. Ideally separated by various folder names which represent your "domains". As your app grows, this becomes more and more monolithic though.
The following structure is a common example of this kind of monolithic code organization:
1└─ myvueapp
2 ├─ ...
3 ├─ src
4 │ ├─ app
5 │ │ ├─ products
6 │ │ ├─ cart
7 │ │ ├─ ui
8 │ │ ├─ ...
9 │ │ └─ App.vue
10 │ ├─ ...
11 │ └─ main.ts
12 ├─ ...
13 ├─ package.json
14 ├─ ...
15
Nx allows you to separate this logic into "local libraries". The main benefits include
- better separation of concerns
- better reusability
- more explicit "APIs" between your "domain areas"
- better scalability in CI by enabling independent test/lint/build commands for each library
- better scalability in your teams by allowing different teams to work on separate libraries
Creating Local Libraries
Let's assume our domain areas include products
, orders
and some more generic design system components, called ui
. We can generate a new library for each of these areas using the Vue library generator:
1nx g @nx/vue:library products --directory=modules/products --unit-test-runner=vitest --bundler=vite
2nx g @nx/vue:library orders --directory=modules/orders --unit-test-runner=vitest --bundler=vite
3nx g @nx/vue:library shared-ui --directory=modules/shared/ui --unit-test-runner=vitest --bundler=vite
4
Note how we use the --directory
flag to place the libraries into a subfolder. You can choose whatever folder structure you like, even keep all of them at the root-level.
Running the above commands should lead to the following directory structure:
1└─ myvueapp
2 ├─ ...
3 ├─ e2e/
4 ├─ modules
5 │ ├─ products
6 │ │ ├─ .eslintrc.json
7 │ │ ├─ README.md
8 │ │ ├─ vite.config.ts
9 │ │ ├─ package.json
10 │ │ ├─ project.json
11 │ │ ├─ src
12 │ │ │ ├─ index.ts
13 │ │ │ ├─ components
14 │ │ │ │ ├─ products.spec.ts
15 │ │ │ │ └─ products.vue
16 │ │ │ └─ vue-shims.d.ts
17 │ │ ├─ tsconfig.json
18 │ │ ├─ tsconfig.lib.json
19 │ │ └─ tsconfig.spec.json
20 │ ├─ orders
21 │ │ ├─ ...
22 │ │ ├─ src
23 │ │ │ ├─ index.ts
24 │ │ │ ├─ components
25 │ │ │ │ ├─ ...
26 │ │ │ │ └─ orders.vue
27 │ │ ├─ ...
28 │ └─ shared
29 │ └─ ui
30 │ ├─ ...
31 │ ├─ src
32 │ │ ├─ index.ts
33 │ │ └─ components
34 │ │ └─ shared-ui.vue
35 │ └─ ...
36 ├─ ...
37 ├─ src
38 │ ├─ app
39 │ │ ├─ ...
40 │ │ ├─ App.vue
41 │ ├─ ...
42 ├─ ...
43
Each of these libraries
- has a project details view where you can see the available tasks (e.g. running tests for just orders:
nx test orders
) - has its own
project.json
file where you can customize targets - has a dedicated
index.ts
file which is the "public API" of the library - is mapped in the
tsconfig.base.json
at the root of the workspace
Importing Libraries into the Vue Application
All libraries that we generate automatically have aliases created in the root-level tsconfig.base.json
.
1{
2 "compilerOptions": {
3 ...
4 "paths": {
5 "@myvueapp/orders": ["modules/orders/src/index.ts"],
6 "@myvueapp/products": ["modules/products/src/index.ts"],
7 "@myvueapp/shared-ui": ["modules/shared/ui/src/index.ts"]
8 },
9 ...
10 },
11}
12
Hence we can easily import them into other libraries and our Vue application. As an example, let's create and expose a ProductList
component from our modules/products
library. Either create it by hand or run
❯
nx g @nx/vue:component product-list --directory=modules/products/src/product-list
We don't need to implement anything fancy as we just want to learn how to import it into our main Vue application.
1<script setup lang="ts">
2// defineProps<{}>()
3</script>
4
5<template>
6 <p>Welcome to ProductList!</p>
7</template>
8
9<style scoped></style>
10
Make sure the ProductList
is exported via the index.ts
file of our products
library. This is our public API with the rest of the workspace. Only export what's really necessary to be usable outside the library itself.
1export { default as ProductList } from './product-list/product-list.vue';
2
We're ready to import it into our main application now. First, let's set up the Vue Router.
❯
npm add vue-router
Configure it in the main.ts
file.
1import './styles.css';
2
3import { createApp } from 'vue';
4import App from './app/App.vue';
5import NxWelcome from './app/NxWelcome.vue';
6import * as VueRouter from 'vue-router';
7
8const routes = [
9 { path: '/', component: NxWelcome },
10 {
11 path: '/products',
12 component: () => import('@myvueapp/products').then((m) => m.ProductList),
13 },
14];
15
16const router = VueRouter.createRouter({
17 history: VueRouter.createWebHashHistory(),
18 routes,
19});
20
21const app = createApp(App);
22
23app.use(router);
24app.mount('#root');
25
Then we can set up navigation links and the RouterView
in the main App
component.
1<script setup lang="ts">
2import { RouterLink, RouterView } from 'vue-router';
3</script>
4
5<template>
6 <nav>
7 <ul>
8 <li>
9 <RouterLink to="/">Home</RouterLink>
10 </li>
11 <li>
12 <RouterLink to="/products">Products</RouterLink>
13 </li>
14 </ul>
15 </nav>
16
17 <RouterView />
18</template>
19
If you now navigate to http://localhost:4200/#/products you should see the ProductList
component being rendered.
Let's do the same process for our orders
library. Create an OrderList
component and import it into the main.ts
routes:
1import './styles.css';
2
3import { createApp } from 'vue';
4import App from './app/App.vue';
5import NxWelcome from './app/NxWelcome.vue';
6import * as VueRouter from 'vue-router';
7
8const routes = [
9 { path: '/', component: NxWelcome },
10 {
11 path: '/products',
12 component: () => import('@myvueapp/products').then((m) => m.ProductList),
13 },
14 {
15 path: '/orders',
16 component: () => import('@myvueapp/orders').then((m) => m.OrderList),
17 },
18];
19
20const router = VueRouter.createRouter({
21 history: VueRouter.createWebHashHistory(),
22 routes,
23});
24
25const app = createApp(App);
26
27app.use(router);
28app.mount('#root');
29
And update the navigation links:
1<script setup lang="ts">
2import { RouterLink, RouterView } from 'vue-router';
3</script>
4
5<template>
6 <nav>
7 <ul>
8 <li>
9 <RouterLink to="/">Home</RouterLink>
10 </li>
11 <li>
12 <RouterLink to="/products">Products</RouterLink>
13 </li>
14 <li>
15 <RouterLink to="/orders">Orders</RouterLink>
16 </li>
17 </ul>
18 </nav>
19
20 <RouterView />
21</template>
22
Similarly, navigating to http://localhost:4200/#/orders should now render the Orders
component.
Note that both the Products
component and Orders
component are lazy loaded so the initial bundle size will be smaller.
Visualizing your Project Structure
Nx automatically detects the dependencies between the various parts of your workspace and builds a project graph. This graph is used by Nx to perform various optimizations such as determining the correct order of execution when running tasks like nx build
, identifying affected projects and more. Interestingly you can also visualize it.
Just run:
❯
nx graph
You should be able to see something similar to the following in your browser (hint: click the "Show all projects" button).
Notice how shared-ui
is not yet connected to anything because we didn't import it in any of our projects. Also the arrows to orders
and products
are dashed because we're using lazy imports.
Exercise for you: change the codebase so that shared-ui
is used by orders
and products
. Note: you need to restart the nx graph
command to update the graph visualization or run the CLI command with the --watch
flag.
Imposing Constraints with Module Boundary Rules
Once you modularize your codebase you want to make sure that the modules are not coupled to each other in an uncontrolled way. Here are some examples of how we might want to guard our small demo workspace:
- we might want to allow
orders
to import fromshared-ui
but not the other way around - we might want to allow
orders
to import fromproducts
but not the other way around - we might want to allow all libraries to import the
shared-ui
components, but not the other way around
When building these kinds of constraints you usually have two dimensions:
- type of project: what is the type of your library. Example: "feature" library, "utility" library, "data-access" library, "ui" library (see library types)
- scope (domain) of the project: what domain area is covered by the project. Example: "orders", "products", "shared" ... this really depends on the type of product you're developing
Nx comes with a generic mechanism that allows you to assign "tags" to projects. "tags" are arbitrary strings you can assign to a project that can be used later when defining boundaries between projects. For example, go to the project.json
of your orders
library and assign the tags type:feature
and scope:orders
to it.
1{
2 ...
3 "tags": ["type:feature", "scope:orders"],
4 ...
5}
6
Then go to the project.json
of your products
library and assign the tags type:feature
and scope:products
to it.
1{
2 ...
3 "tags": ["type:feature", "scope:products"],
4 ...
5}
6
Finally, go to the project.json
of the shared-ui
library and assign the tags type:ui
and scope:shared
to it.
1{
2 ...
3 "tags": ["type:ui", "scope:shared"],
4 ...
5}
6
Notice how we assign scope:shared
to our UI library because it is intended to be used throughout the workspace.
Next, let's come up with a set of rules based on these tags:
type:feature
should be able to import fromtype:feature
andtype:ui
type:ui
should only be able to import fromtype:ui
scope:orders
should be able to import fromscope:orders
,scope:shared
andscope:products
scope:products
should be able to import fromscope:products
andscope:shared
To enforce the rules, Nx ships with a custom ESLint rule. Open the .eslintrc.base.json
at the root of the workspace and add the following depConstraints
in the @nx/enforce-module-boundaries
rule configuration:
1{
2 ...
3 "overrides": [
4 {
5 ...
6 "rules": {
7 "@nx/enforce-module-boundaries": [
8 "error",
9 {
10 "enforceBuildableLibDependency": true,
11 "allow": [],
12 "depConstraints": [
13 {
14 "sourceTag": "*",
15 "onlyDependOnLibsWithTags": ["*"]
16 },
17 {
18 "sourceTag": "type:feature",
19 "onlyDependOnLibsWithTags": ["type:feature", "type:ui"]
20 },
21 {
22 "sourceTag": "type:ui",
23 "onlyDependOnLibsWithTags": ["type:ui"]
24 },
25 {
26 "sourceTag": "scope:orders",
27 "onlyDependOnLibsWithTags": [
28 "scope:orders",
29 "scope:products",
30 "scope:shared"
31 ]
32 },
33 {
34 "sourceTag": "scope:products",
35 "onlyDependOnLibsWithTags": ["scope:products", "scope:shared"]
36 },
37 {
38 "sourceTag": "scope:shared",
39 "onlyDependOnLibsWithTags": ["scope:shared"]
40 }
41 ]
42 }
43 ]
44 }
45 },
46 ...
47 ]
48}
49
To test it, go to your modules/products/src/lib/products.vue
file and import the Orders
component from the orders
project:
1<script setup lang="ts">
2defineProps<{}>();
3
4// 👇 this import is not allowed
5import { Orders } from 'orders';
6</script>
7
8<template>
9 <p>Welcome to Products!</p>
10</template>
11
12<style scoped></style>
13
If you lint your workspace you'll get an error now:
~/workspace❯
nx run-many -t lint
1 > NX Running target lint for 5 projects
2 ✖ nx run products:lint
3 Linting "products"...
4
5 /Users/isaac/Documents/code/nx-recipes/vue-standalone/modules/products/src/lib/products.vue
6 5:1 error A project tagged with "scope:products" can only depend on libs tagged with "scope:products", "scope:shared" @nx/enforce-module-boundaries
7 5:10 warning 'Orders' is defined but never used @typescript-eslint/no-unused-vars
8
9 ✖ 2 problems (1 error, 1 warning)
10
11 Lint warnings found in the listed files.
12
13 Lint errors found in the listed files.
14
15
16 ✔ nx run orders:lint (913ms)
17 ✔ nx run e2e:lint [existing outputs match the cache, left as is]
18 ✔ nx run myvueapp:lint (870ms)
19 ✔ nx run shared-ui:lint (688ms)
20
21 ——————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
22
23 > NX Ran target lint for 5 projects (2s)
24
25 ✔ 4/5 succeeded [1 read from cache]
26
27 ✖ 1/5 targets failed, including the following:
28 - nx run products:lint
29
30
Learn more about how to enforce module boundaries.
Migrating to a Monorepo
When you are ready to add another application to the repo, you'll probably want to move myvueapp
to its own folder. To do this, you can run the convert-to-monorepo
generator or manually move the configuration files.
Next Steps
Congrats, you made it!! You now know how to leverage the Nx standalone applications preset to build modular Vue applications.
Here's some more things you can dive into next:
- Learn more about the underlying mental model of Nx
- Speed up CI: Run only tasks for project that got changed
- Speed up CI: Share your cache
- Speed up CI: Distribute your tasks across machines
Also, make sure you
- Join the Official Nx Discord Server to ask questions and find out the latest news about Nx.
- Follow Nx on Twitter to stay up to date with Nx news
- Read our Nx blog
- Subscribe to our Youtube channel for demos and Nx insights