DEV Community

Cover image for Using Modules and Pinia to structure Nuxt 3 app
Jakub Andrzejewski
Jakub Andrzejewski

Posted on • Updated on

Using Modules and Pinia to structure Nuxt 3 app

Building a Hello World application in Nuxt 3 is relatively simple, but you will most probably reach a stage in your project where you will need to structure your application in order to have easy customizability and extendability for future upgrades and new features. For that you could utilize the concept of Modules.

This article is an inspiration from @davorminchorov. Thanks for suggesting me this topic as I learned quite a few things while writing it :)

Modules

Modules are used to extend Nuxt core functionality. These modules can contain their own components, composables, pages, plugins, and even a server middleware. By having all this modular functionality we can easily bound context of certain business domain (bit like Domain Driven Design that you can read more about in the Bonus Links). Take a look at this infographic for more information regarding how modules are evaluated in Nuxt app. (This is for Nuxt 2 but it works very similar in Nuxt3. Probably, a Nuxt 3 docs about modules will be released soon).

Nuxt modules

You can read more about modules here

Nuxt 3 example

Now, that we know what the modules are, let's dive in into the code to see how we can utilize them to structure our application.

If you get lost at some point you can check out the Github repository that I have created for this article that contains all the steps covered in this tutorial -> https://github.com/Baroshem/nuxt3-structure-modules-pinia

Setting up a boilerplate Nuxt 3 project

Let's start with generating an empty Nuxt 3 project. We can do so by typing following command in your terminal:

npx nuxi init nuxt3-pinia
Enter fullscreen mode Exit fullscreen mode

When you open your new created project in your code editor you should see following result:

Nuxt 3 project in VS Code

Now, let's install dependencies of the project:

yarn # npm install
Enter fullscreen mode Exit fullscreen mode

And start the project to see if it is working as expected:

yarn dev # npm run dev
Enter fullscreen mode Exit fullscreen mode

If everything went good, we should see following result in our browser:

Nuxt 3 in the browser

Add Pinia

Now, as we have a boilerplate project running let's add a Pinia store to it. If you haven't tried Pinia yet I will highly recommend you to do that.

Pinia is a store library for Vue, it allows you to share a state across components/pages.

As we already know what is Pinia, let's dive into the code and add it to the Nuxt 3 project.

First, let's install @pinia/nuxt and pinia packages

yarn add @pinia/nuxt pinia
Enter fullscreen mode Exit fullscreen mode

Next, add the @pinia/nuxt to the modules section of nuxt.config.ts

// nuxt.config.ts

import { defineNuxtConfig } from 'nuxt'

export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt'
  ]
})
Enter fullscreen mode Exit fullscreen mode

To test, whether Pinia is working as expected, let's create a simple store

// store/test.ts

import { defineStore } from 'pinia'

export const useTest = defineStore({
  id: 'test',

  state: () => ({
    value: 1
  }),

  getters: {
    valueWithName: state => `Value is ${state.value}`
  },

  actions: {
    setNewValue(newValue: number) {
      this.value = newValue
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Similarly, to how Vuex was used in Vue and Nuxt 2, here we have an initial state, getters, and actions:

  • state is a reactive state that can be shared across the application and will be modified accordingly when updated globally
  • getters are used to get certain state value (that can also be combined with something else like a text or computed value)
  • actions are used to modify the initial value of state

Now, let's check if our newly created store is registered and if we can access the state value in the app.vue

// app.vue

<template>
  <div>
    {{ test.value }}
    <NuxtWelcome />
  </div>
</template>

<script setup lang="ts">
import { useTest } from "~/store/test";
const test = useTest()
</script>
Enter fullscreen mode Exit fullscreen mode

Check out the browser to see the result.

Pinia store value

Great, it works!

New Blog Module

For the sake of this tutorial we will create a simple blog module that will contain it's own components, pages, composables and Pinia store.

But first, let's remove previously create store/test.ts file and remove its declaration from the app.vue as we will not need it anymore. Let's also remove NuxtWelcome component and instead add a NuxtPage component like following:

<template>
  <div>
    <NuxtPage/>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

In order for this functionality to work correctly, we have to have a pages directory in our root project (NuxtPage is automatically registered when pages directory is defined).

For this tutorial we won't need any page inside it so we can leave it empty (in the code repository it contains a simple .gitkeep file).

Now, we will move into /blog directory and we will create components, composables, pages, and store.

Components

For this tutorial let' create just a simple Vue 3 component called BlogPost that will accept a blog content and will display it accordingly inside a div

// modules/blog/components/BlogPost.vue

<template>
  <div>
    {{ blog }}
  </div>
</template>

<script setup lang="ts">
const props = defineProps({
  blog: {
    type: String,
    required: true
  }
})
</script>
Enter fullscreen mode Exit fullscreen mode

Composables

To manage state across the application we can use Pinia or composables but for this tutorial I wanted to show that you can use both solutions at the same time or choose the one that suits you best.

// modules/blog/composables/useBlog.ts

import { useState } from "#app"

export const useBlog = () => {
  const blogPostId = useState('blog-post-id', () => 1)

  return {
    blog: `Test blog post ${blogPostId.value}`
  }
}
Enter fullscreen mode Exit fullscreen mode

Store

This is a very similar store that we have created previously to test whether the Pinia works correctly. In this case we have modified the store id and the name to useBlogStore.

// modules/blog/store/stores.ts

import { defineStore } from 'pinia'

export const useBlogStore = defineStore({
  id: 'blog-store',

  state: () => ({
    value: 1
  }),

  getters: {
    valueWithName: state => `Value is ${state.value}`
  },

  actions: {
    setNewValue(newValue: number) {
      this.value = newValue
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Pages

In this page we will use previously created composable, component, and Pinia store to display this content and value of current blog id from router.

// modules/blog/pages/blog/[id].vue

<template>
  <section>
    <p>Blog post with the id: {{ currentRoute.params.id }}</p>
    <BlogPost :blog="blog"/>
    <span>Current value from blogStore: {{ blogStore.value }}</span>
  </section>
</template>

<script setup lang="ts">
import { useBlog } from '../../composables/useBlog';
import BlogPost from '../../components/BlogPost.vue';
import { useBlogStore } from '../../store/store';

const { currentRoute } = useRouter()
const { blog } = useBlog()
const blogStore = useBlogStore()
</script>
Enter fullscreen mode Exit fullscreen mode

What is happening here is that we are importing:

  • useBlog composable and destructuring a blog property from it.
  • useBlogStore Pinia store so that we can have access to the state.
  • BlogPost component and we are passing a blog property from useBlog composable directly to it.

By combining these three things we can display in the page /blog/:id a text with current id thanks to router, blog content using composable and component, and value from Pinia store.

Wrapping all into a module

The previous step was already bit difficult so here I will make it as short and simple as possible. To make previous part available in our Nuxt 3 app, let's create a module that will wrap components, composables, pages, and store into one easy to import module.

// modules/blog/module.ts

import { defineNuxtModule } from '@nuxt/kit'
import { resolve, join } from 'pathe'
import type { Nuxt } from '@nuxt/schema'

export default defineNuxtModule({
  name: 'blog-module',
  configKey: 'blog-module',
  setup (options: any, nuxt: Nuxt) {

    // Auto register components
    nuxt.hook('components:dirs', (dirs) => {
      dirs.push({
        path: join(__dirname, 'components')
      })
    })

    // Auto register composables
    nuxt.hook('autoImports:dirs', (dirs) => {
      dirs.push(resolve(__dirname, './composables'))
    })

    // Auto register pages
    nuxt.hook('pages:extend', (pages) => {
      pages.push({
        name: 'blog-page',
        path: '/blog/:id',
        file: resolve(__dirname, './pages/blog/[id].vue')
      })
    })

    // Pinia store modules are auto imported
  }
})
Enter fullscreen mode Exit fullscreen mode

Let's discuss it step by step:

  1. We are defining a new Nuxt module.
  2. We are giving it a name of blog-module for easier recognition and distinction.
  3. We are giving it a config key (in our case the same as the name) that can be used to pass some options from nuxt.config.ts directly to the module.
  4. In the setup function we are defining what should happen on module registration.
  5. In our case, we want to automatically register components, composables, pages, and stores so that we can use the across the application

The last step now, is to add our newly created module to the modules section of nuxt.config.ts:

// nuxt.config.ts

import { defineNuxtConfig } from 'nuxt'

// https://v3.nuxtjs.org/docs/directory-structure/nuxt.config
export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt',
    '~/modules/blog/module'
  ]
})
Enter fullscreen mode Exit fullscreen mode

We are adding it to the modules so that all things like components or composables are auto imported.

And that's it! We now have a module that is containing all the functionality related to the blog.

Summary

You have managed to create a blog module that contains it's own components, composables, pages, and stores. Well done! There was a lot of knowledge to cover here and it should be a solid start that will enable you to structure your next (Nuxt ;)) project more easily. Make sure to experiment with this approach as there might be more interesting functionalities that could be encapsulated like that :)

Bonus

Top comments (23)

Collapse
 
avxkim profile image
Alexander Kim

Hey, is it possible to install another module in my module?

    await installModule('@nuxtjs/tailwindcss', {
      exposeConfig: true,
    })
Enter fullscreen mode Exit fullscreen mode

i initially thought this would be installing this module automatically, when i'm using my module inside another app by including it nuxt.config.ts:

modules: ['@someorg/my-module']
Enter fullscreen mode Exit fullscreen mode

but looks like it won't install this module automatically. Is there a way to do so?

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Hey there,

I am using this approach in my Nuxt Security module where I use the module for CSRF detection.

github.com/Baroshem/nuxt-security/...

Maybe this will help you. The module is also installed in the project package.json file.

Collapse
 
avxkim profile image
Alexander Kim

installModule just installs the module in a consumer app's nuxt.config.ts modules section right? But as i figured out, you have to install module dependency manually anyway

Thread Thread
 
jacobandrewsky profile image
Jakub Andrzejewski

Yes, you have to install it manually in the package.json in order for it to be available.

Collapse
 
cthulhudev profile image
CthulhuDev

Great article!
Modules in nuxt3 are so cool.
It would be great to explore how to divide a complex application (let's say, an ecommerce JAMStack webapp) into handy submodules.

Just a quick thing: I think buildModules in nuxt config is going to be discouraged in nuxt3 since every module is now a build module. Am I correct?

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Yes you are right. From now on, you can use normal modules instead.

I am waiting for the release of Nuxt 3 with RC version that will support SSG. When it will be available, then I will make such an article :)

Collapse
 
cthulhudev profile image
CthulhuDev

It's already available since some hours ago ❤️

Collapse
 
cavalcanteleo profile image
Leonardo

One of the greatest features Nuxt has is folder based routing.

But with this module example, we need to register all routes manually:

nuxt.hook('pages:extend', (pages) => {
  pages.push({
    name: 'blog-page',
    path: '/blog/:id',
    file: resolve(__dirname, './pages/blog/[id].vue')
  })
})
Enter fullscreen mode Exit fullscreen mode

is there a way to automatic create a route for every file inside the pages directory?``

Collapse
 
andyjamesn profile image
andyjamesn

Could you explain a little about how the Pinia integration works?

In the module.ts you state

// Pinia store modules are auto imported

How are they setup to auto import? Is it because useBlogStore is being imported in the blob/[id] component?

Is it possible to use Pinia without a component in the module to store data eg: logged in user data and access it from components in the main app that is importing the module?

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

So basically Pinia works in all places no matter where you import it in your application. If you import it in the module, this value will be then accessible from somewhere else. It is done because Pinia is a store that can be accessible from anywhere in the app.

Yes, it is possible to use Pinia without a component. It can be used in the composables as well or page. I just showed it as an example of usage :)

Collapse
 
andyjamesn profile image
andyjamesn

Excellent thank you! I did dive deep into their docs and came to this conclusion. I essentially import the stores in my plugin.ts file inside defineNuxtPlugin with const auth = useAuthStore(nuxtApp.$pinia)

This adds the store globally and then it is accessible anywhere in the app.

Collapse
 
andyjamesn profile image
andyjamesn

Does this method still work on the latest 3.0.0-rc.3

I am getting the following error. I have reproduced it with identical code as described in this tutorial.

ERROR Cannot restart nuxt: Nuxt module should be a function: [object Object] 15:05:41

at normalizeModule (/Users/A/FlowR/ui-kit-repos/flowr-app/node_modules/@nuxt/kit/dist/index.mjs:417:11)
at installModule (/Users/A/FlowR/ui-kit-repos/flowr-app/node_modules/@nuxt/kit/dist/index.mjs:397:47)
at initNuxt (/Users/A/FlowR/ui-kit-repos/flowr-app/node_modules/nuxt/dist/index.mjs:1336:13)
at async load (/Users/A/FlowR/ui-kit-repos/flowr-app/node_modules/nuxi/dist/chunks/dev.mjs:6734:9)
at async _applyPromised (/Users/A/FlowR/ui-kit-repos/flowr-app/node_modules/nuxi/dist/chunks/dev.mjs:6686:10)

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Hey,

I wrote this article some time ago and the structure of nuxt.config.ts file changed a bit. Basically if you have a nuxt 3 rc.3 project the only thing you have to change is from buildModules to modules and do not use object syntax to define a local module but just use a path i.e.

  modules: [
    '@pinia/nuxt',
    '~/modules/blog/module'
  ]
Enter fullscreen mode Exit fullscreen mode

Thans for noticing that. I will update the article to be up to date :)

Collapse
 
andyjamesn profile image
andyjamesn

Thanks, I managed to work it out by looking at the git repository you linked to in your article.

One other thing worth mentioning is I had to do was yarn add @nuxt/kit

Collapse
 
intermundos profile image
intermundos

Nuxt 3 is a complete disappointment. Lack of documentation, lots of errors and behaviours. Started new project and threw it in a trash bin. Sveltekit look more friendlier and usable from the start.

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Wow, I am pretty sad to read it. Could you tell me more why you did not like the experience?

Collapse
 
intermundos profile image
intermundos

I will try to explain.

This is my 2nd time I try to use Nuxt 3.

  1. Documentation is lacking and unclear
  2. Things just don't work - I have useFetch in page to fetch data from API. When I open the app on this route, nothing is fetched, when I switch to other route and come back, data is fetched.
  3. No clear separation between client/server

These 3 are enough. Nuxt 3 feels very raw. Sveltekit IMHO does these thing in a more elegant and clear way.

Thread Thread
 
jacobandrewsky profile image
Jakub Andrzejewski

I am sad that this is your experience with Nuxt 3 but I completely understand that you can have different (sometimes better) experience with other tools and you are completely fine with that! These tools are learning from each other constantly so I expect Nuxt 3 to become the same level quite soon :)

Collapse
 
benyaminrmb profile image
BenyaminRmb

why should we use this instead of vuex ?

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Hi Benyamin,

Pinia is recommended approach as a state management tool for Vue 3 also recommended by Evan You as it works really well with Composition API. You can check out the explanation video here -> youtu.be/2KBHvaAWJOA?t=896

Also, I think that you can easily switch it to Vuex and then import the store using a plugin as it is done in the bonus links.

Collapse
 
ibm5100 profile image
IBM51OO

Hello, thanks for the article, I had a problem when in the page that is declared in the module, declared middleware through definePageMeta, I get an warning:
definePageMeta() is a compiler-hint helper that is only usable inside the script block of a single file component which is also a page. Its arguments should be compiled away and passing
it at runtime has no effect.
If anyone knows how to fix it ?

Collapse
 
rodrigoblanco profile image
RodrigoBlanco • Edited

Hi, Jakub! I followed your guide and I getting an issue when I try to add tailwind classes inside a page from a module. If I use a tailwind class in app.vue file, works correctly. Why can happen this?

Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Hmm, this is rather strange. Could you tell me a bit more about this issue? Are they not working or is there any bug in the terminal?