Creating Your First Nuxt App – A CRUD Book Store

Introduction

Nuxt.js is a progressive web application (PWA) framework that builds on top of Vue.js to create server-rendered Vue applications. It provides an organized, optimized development structure out of the box along with advanced features like asynchronous data loading, middleware, and route transitions.

In this comprehensive guide, we will build a CRUD (Create, Read, Update, Delete) book store application from scratch using Nuxt.js and Vuetify.

Prerequisites

Before starting, you should have a basic understanding of:

  • Vue.js fundamentals like components, props, data binding etc.
  • JavaScript fundamentals like variables, functions, arrays, objects etc.
  • ES6 syntax and features
  • Node.js and NPM basics

Project Overview

We will build a book store app that allows users to add books to three categories:

  • Recently Read
  • Favorites
  • Best of the Best

For each book, we will store the title, author, description and category.

The app will have the following features:

  • Display books grouped under their respective categories
  • Add a new book
  • Edit an existing book
  • Delete a book
  • View book details

We will use Vuex to manage state and Vuetify as the UI framework.

Scaffolding the Nuxt App

Let‘s start by scaffolding out the basic Nuxt app structure using the create-nuxt-app command:

npx create-nuxt-app bookstore

Choose the following options when prompted:

  • Project name: bookstore
  • Programming language: JavaScript
  • Package manager: Npm
  • UI framework: Vuetify.js
  • Nuxt.js modules: Axios
  • Linting tools: ESLint
  • Testing framework: None
  • Rendering mode: Universal (SSR)

This will generate the Nuxt project structure with the options we selected.

Installing Dependencies

Now install the project dependencies using:

cd bookstore
npm install

Project Structure

The main project folders and files generated are:

  • pages – Contains the application views and routes
  • components – Contains reusable Vue components
  • layouts – Layout templates that wrap pages
  • store – Handles state management with Vuex
  • middleware – Handles custom middleware
  • plugins – Plugins to inject dependencies into Vue
  • nuxt.config.js – Nuxt configuration file

Creating Layout

Open layouts/default.vue and update it to following:

<template>
  <v-app>

    <v-navigation-drawer v-model="drawer" app>
       <!-- navigation links -->
    </v-navigation-drawer>

    <v-app-bar 
      app
      color="indigo"
      dark
    >
      <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>

      <v-toolbar-title>My BookStore</v-toolbar-title>

      <v-spacer></v-spacer>

      <v-btn text @click="openAddBookDialog">
        <v-icon left>mdi-plus</v-icon>        
        Add Book
      </v-btn>

    </v-app-bar>

    <v-main>
      <v-container>        
        <Nuxt />
      </v-container>
    </v-main>

    <add-book-dialog/>

  </v-app>
</template>

<script>
import AddBookDialog from ‘@/components/AddBookDialog‘

export default {
  components: {
    AddBookDialog 
  },
  data () {
    return {
      drawer: false
    }
  },
  methods: {
    openAddBookDialog () {
      this.$store.commit(‘openAddBook‘)
    } 
  }  
}
</script>

This sets up the basic page layout with the header, navigation drawer and page content section to display the active Nuxt page or route.

We add the <add-book-dialog> component which we‘ll build later to handle adding books.

The openAddBookDialog method will open this dialog by committing a state change using Vuex.

Registering Vuex Store

Create a file called store/index.js and add the following code:

export const state = () => ({
  addBookDialog: false  
})

export const mutations = {
  openAddBook(state) {
    state.addBookDialog = true; 
  },
  closeAddBook(state) {
   state.addBookDialog = false
  }
}

This creates the Vuex store with an addBookDialog state that tracks whether the add book dialog box is open or closed.

The mutations allow committing changes to update this state.

Now open nuxt.config.js and register the Vuex store module:

  modules: [
    ‘@/store‘
  ],

Creating Book Model

Create a file model/book.js with a basic book model:

export default class Book {
  constructor(title, author, description, category) {
    this.title = title
    this.author = author 
    this.description = description
    this.category = category
  }
}

This exports a Book class that we instantiate and use later.

Building the UI Components

Under components, create the following files:

BookCard.vue

<template>
  <v-card>
    <v-card-title>{{ book.title }}</v-card-title>    

    <v-card-text>
      <div>Author: {{ book.author }}</div>
      <div>Category: {{ book.category }}</div>

      <v-spacer></v-spacer>

      <div class="text-truncate">{{ book.description }}</div>
    </v-card-text>

    <v-card-actions>
      <v-btn text>
        View Details
      </v-btn>

      <v-spacer></v-spacer>

      <v-btn icon @click="editBook">
        <v-icon>mdi-pencil</v-icon>
      </v-btn>

      <v-btn icon @click="deleteBook">
       <v-icon>mdi-delete</v-icon>
      </v-btn> 
    </v-card-actions>
  </v-card>  
</template>

<script>
export default {
  props: {
    book: {
      type: Object,
      required: true
    }
  },  
  methods: {
    editBook() {
      this.$emit(‘edit‘, this.book)
    },
    deleteBook() {
      this.$emit(‘delete‘, this.book) 
    }
  }
}
</script>

This component displays the book details passed via a book prop. It has edit and delete buttons which emit events for the parent component to handle.

AddBookDialog.vue

<template>
  <v-dialog 
    v-model="open"
    max-width="600"
  >
    .. dialog content
  </v-dialog>  
</template>

<script>
export default {

  computed: {
    open: {
      get() {
        return this.$store.state.addBookDialog
      },
      set(value) {
        if(!value) {
          this.$store.commit(‘closeAddBook‘)
        }
      }
    } 
  },

}  
</script>

This component renders the add book dialog conditionally based on the Vuex addBookDialog state. When closed, it commits the state change.

Building the Pages

Under pages, create an index.vue file that handles the book listings:

<template>

  <v-row>

    <v-col 
      v-for="category in categories"
      :key="category.name" 
      cols="12" 
      md="4"
    >

      <h2 class="text-center">{{ category.name }}</h2>

      <book-card
        v-for="(book, index) in category.books" 
        :key="index"
        :book="book"
        @edit="editBook"
        @delete="deleteBook"  
      />

      <v-btn 
        color="primary"
        @click="addBook(category.name)"
      >
        Add New Book  
      </v-btn>

    </v-col>

  </v-row>  

</template>

<script>
import BookCard from ‘@/components/BookCard‘

export default {

  components: {
    BookCard
  },

  data() {
    return {
      categories: [
        {
          name: ‘Recently Read‘,
          books: [] 
        },
        { 
          name: ‘Favorites‘,
          books: []
        },
        {
          name: ‘Best Books‘, 
          books: []
        } 
      ]
    }
  },

  methods: {

    addBook(category) {
      // open add book dialog
      // update state on save
    },

    editBook(book) {
      // open add book dialog
      // populate with book data
    },

    deleteBook(book) {
      // delete book
    }

  }

}
</script>

This page has a grid that displays books under their categories.

The BookCard component is used to display each book‘s data.

Placeholder methods are added for now to handle CRUD operations which we‘ll implement soon.

Under pages, create a `_slug.vue file to handle the book details page:

<template>

  <v-card>

    <v-card-title>{{ book.title }}</v-card-title>

    <v-card-text>
      <div>Author: {{ book.author }}</div>
      <div>Category: {{ book.category }}</div>

      <v-spacer></v-spacer>

      <div>{{ book.description }}</div>
    </v-card-text>

  </v-card>

</template>

<script>
export default {
  async asyncData ({ params }) {
    // fetch book data using slug 
    const book = {}

    return { book }
  }  
}
</script>

This makes an async call to fetch the book data dynamically based on route params.

With the core UI components and pages built out, let‘s work on the functionality.

Implementing CRUD Functionality

Adding Books

In the Vuex store, add some mock data:

export const state = () => ({

  books: [
    { 
      name: ‘Atomic Habits‘,
      author: ‘James Clear‘,
      category: ‘Recently Read‘,
      description: ‘Methods to build good habits and break bad ones‘  
    },
    {
      name: ‘The Great Gatsby‘,
      author: ‘F. Scott Fitzgerald‘,
      category: ‘Favorites‘,
      description: ‘The story primarily concerns the young and..."
    }
  ]

})

Update openAddBook mutation:

openAddBook(state) {
  state.addBookDialog = true

  state.selectedBook = {
    name: ‘‘,
    author: ‘‘, 
    description: ‘‘,
    category: ‘‘ 
  } 
}

Now update AddBookDialog.vue:

<template>
 <v-form @submit="addBook">

   <!-- form controls to get book details -->

 </v-form>
</template>

<script>
import Book from ‘@/models/book‘

export default {

  computed: {
    book: {
      get() {
        return {...this.$store.state.selectedBook}  
      },
      set(book) {
        this.$store.commit(‘updateSelectedBook‘, book)
      }
    }
  },

  methods: {
    addBook() {
     const book = new Book(this.name, this.author, ...) 

     this.$store.commit(‘addBook‘, book)

     this.$store.commit(‘closeAddBook‘) 
    }
  }  

}
</script>

The component syncs with the Vuex selectedBook state and populates the form using two-way binding.

On submit, it instantiates a new Book and dispatches a mutation to add it to state.

In the store, handle adding books:

export const mutations = {
  updateSelectedBook(state, book) {
    state.selectedBook = book
  },

  addBook(state, book) {
    state.books.push(book)   
  }  
}

Updating Books

Handle editing books in index.vue:

editBook(book) {
  this.$store.commit(‘openAddBook‘)

  this.$store.commit(‘updateSelectedBook‘, book) 
}

This dispatches mutations to open the add book dialog and populate the form for editing.

Saving the form commits the updated book object to state like when adding a new book.

Deleting Books

Implement deleting books:

deleteBook(book) {
  this.$store.commit(‘removeBook‘, book)  
}
removeBook(state, bookToRemove) {
  state.books = state.books.filter(book => book !== bookToRemove)
}

This commits a mutation by passing the book object to filter it out of state.

Fetching Data from API

Instead of mock data, we can make API calls to fetch and persist books.

Install the axios Nuxt module earlier.

Create an API util file utils/api.js with a getBooks method:

import axios from ‘axios‘

export default {

  getBooks() {
    return axios.get(‘/books‘)
  }

}

Use this in index.vue:

export default {

  async asyncData() {
    const books = await api.getBooks()  

    return {
      books 
    }
  }

}

This fetches the books on the server before rendering the page.

Do the same for other data fetching and persistence.

Conclusion

And that‘s it! We‘ve built a CRUD book store application with Nuxt and Vuetify from scratch.

The key things we learned were:

  • Scaffolding a Nuxt app with useful modules
  • Structuring the project
  • Building reusable components
  • Centralizing state with Vuex
  • Basic CRUD functionality
  • Fetching data on server-side

There‘s a lot more we can do to expand this including improving styling, adding authentication, writing tests and deployment.

For further learning, check out the official Nuxt and Vuetify documentation.

Let me know in the comments about what you would like to see next!