Build a Full‑Stack CRUD App with Node.js, Express, PostgreSQL & Vue 3 + GraphQL (2025 Edition)

by Didin J. on Jun 19, 2025 Build a Full‑Stack CRUD App with Node.js, Express, PostgreSQL & Vue 3 + GraphQL (2025 Edition)

Master modern full‑stack development: build a CRUD app using Node.js, Express, PostgreSQL, GraphQL backend and Vue 3 + Apollo frontend—2025 update.

In today’s web development world, building efficient full‑stack apps requires modern tools and best practices. This comprehensive 2025 edition guide shows you how to build a full‑stack CRUD application using:

  • Backend: Node.js, Express.js, PostgreSQL (via Sequelize), and GraphQL

  • Frontend: Vue 3 + Vue Apollo

You’ll learn how to set up the database, define GraphQL schemas and resolvers, and create a Vue 3 SPA that communicates via Apollo Client. Whether you're building blogs, admin panels, or data-intensive apps, this tutorial gives you a maintainable, type-safe, and high-performance foundation.


1. Project Setup

Backend Directory Structure

backend/
├── models/
├── resolvers/
├── schema/
├── migrations/
├── config/
├── index.js
└── package.json


2. Initialize Backend with Express, PostgreSQL, GraphQL

Initialize Node.js Project

mkdir backend && cd backend
npm init -y

Install Dependencies

npm install express @apollo/server graphql sequelize pg pg-hstore cors dotenv body-parser
npm install --save-dev nodemon

Setup Sequelize

npx sequelize-cli init

Edit config/config.json or use config/config.js with environment variables.

{
  "development": {
    "username": "djamware",
    "password": "dj@mw@r3",
    "database": "node_sequelize",
    "host": "127.0.0.1",
    "dialect": "postgresql"
  },
  ...
}

Create a new PostgreSQL database.

psql postgres -U djamware
create database node_sequelize;
\q


3. Define Sequelize Model

We will use Sequelize-CLI to generate a new model and migration. Type this command to create a model for 'Book'.

sequelize model:generate --name Book --attributes isbn:string,title:string,author:string,description:string,publishedYear:integer,publisher:string

Those commands will generate models and migration files. The content of the model file looks like this.

'use strict';
const {
  Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class Book extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  }
  Book.init({
    isbn: DataTypes.STRING,
    title: DataTypes.STRING,
    author: DataTypes.STRING,
    description: DataTypes.STRING,
    publishedYear: DataTypes.INTEGER,
    publisher: DataTypes.STRING
  }, {
    sequelize,
    modelName: 'Book',
  });
  return Book;
};

And the migration file looks like this.

'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Books', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      isbn: {
        type: Sequelize.STRING
      },
      title: {
        type: Sequelize.STRING
      },
      author: {
        type: Sequelize.STRING
      },
      description: {
        type: Sequelize.STRING
      },
      publishedYear: {
        type: Sequelize.INTEGER
      },
      publisher: {
        type: Sequelize.STRING
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Books');
  }
};

Finally, for migrations, there's nothing to change, and they are all ready to generate the table in the PostgreSQL Database. Type this command to generate the table in the database.

sequelize db:migrate


4. Set up GraphQL Schema & Resolvers

📄 schema/typeDefs.js

const typeDefs = `
  type Book {
    id: ID!
    title: String!
    author: String!
    publishedYear: Int
  }

  type Query {
    books: [Book]
    book(id: ID!): Book
  }

  type Mutation {
    createBook(title: String!, author: String!, publishedYear: Int): Book
    updateBook(id: ID!, title: String, author: String, publishedYear: Int): Book
    deleteBook(id: ID!): Boolean
  }
`;

module.exports = typeDefs;

📄 resolvers/bookResolver.js

const { Book } = require('../models');

module.exports = {
  Query: {
    books: () => Book.findAll(),
    book: (_, { id }) => Book.findByPk(id),
  },
  Mutation: {
    createBook: (_, args) => Book.create(args),
    updateBook: async (_, { id, ...rest }) => {
      await Book.update(rest, { where: { id } });
      return Book.findByPk(id);
    },
    deleteBook: async (_, { id }) => {
      const deleted = await Book.destroy({ where: { id } });
      return deleted > 0;
    },
  },
};


5. Start Apollo Server

📄 index.js

require('dotenv').config();
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { sequelize } = require('./models');
const typeDefs = require('./schema/typeDefs');
const resolvers = require('./resolvers/bookResolver');
const cors = require('cors');

const startServer = async () => {
  const app = express();
  app.use(cors());

  const server = new ApolloServer({ typeDefs, resolvers });
  await server.start();
  server.applyMiddleware({ app });

  const PORT = process.env.PORT || 4000;
  app.listen(PORT, async () => {
    await sequelize.authenticate();
    console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`);
  });
};

startServer();

Start the server:

nodemon

The successful server started like this.

Executing (default): SELECT 1+1 AS result
🟢 DB Connected
🚀 GraphQL Server ready at http://localhost:4000/graphql


6. Vue 3 + Apollo Client Frontend Setup

Tools Used

  • Vue 3 (Composition API)

  • Vue Router

  • Apollo Client (@apollo/client)

  • Vue Apollo (@vue/apollo-composable)


7. Initialize Vue 3 Project

If you haven’t already:

npm init vue@latest vue-graphql-client
cd vue-graphql-client
npm install

Choose:

  • Vue 3

  • Router

  • TypeScript (optional)

  • Pinia (optional)

  • ESLint/Prettier (optional)


8. Install and Set Up Apollo Client

npm install @apollo/client graphql @vue/apollo-composable

📁 src/plugins/apollo.ts

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core';
import { DefaultApolloClient } from '@vue/apollo-composable';

const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql',
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
});

export default {
  install: (app: any) => {
    app.provide(DefaultApolloClient, apolloClient);
  },
};

📄 src/main.js

import "./assets/main.css";

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import ApolloPlugin from "./plugins/apollo";

const app = createApp(App);
app.use(router);
app.use(ApolloPlugin);
app.mount("#app");


9. Define GraphQL Operations

📁 src/graphql/queries.ts

import gql from 'graphql-tag';

export const GET_BOOKS = gql`
  query GetBooks {
    books {
      id
      title
      author
      publishedYear
    }
  }
`;

export const GET_BOOK = gql`
  query GetBook($id: ID!) {
    book(id: $id) {
      id
      title
      author
      publishedYear
    }
  }
`;

📁 src/graphql/mutations.ts

import gql from 'graphql-tag';

export const CREATE_BOOK = gql`
  mutation CreateBook($title: String!, $author: String!, $publishedYear: Int) {
    createBook(title: $title, author: $author, publishedYear: $publishedYear) {
      id
    }
  }
`;

export const UPDATE_BOOK = gql`
  mutation UpdateBook($id: ID!, $title: String, $author: String, $publishedYear: Int) {
    updateBook(id: $id, title: $title, author: $author, publishedYear: $publishedYear) {
      id
    }
  }
`;

export const DELETE_BOOK = gql`
  mutation DeleteBook($id: ID!) {
    deleteBook(id: $id)
  }
`;


10. Add a Book and Display List

📄 src/views/BookList.vue

<template>
  <div>
    <h2>Book List</h2>
    <div v-if="loading">Loading...</div>
    <div v-else>
      <div v-for="book in books" :key="book.id">
        {{ book.title }} — {{ book.author }} ({{ book.publishedYear }})
        <button @click="deleteBook(book.id)">Delete</button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { useQuery, useMutation } from '@vue/apollo-composable';
import { GET_BOOKS } from '@/graphql/queries';
import { DELETE_BOOK } from '@/graphql/mutations';
import { useRouter } from 'vue-router';

const router = useRouter();
const books = ref<any[]>([]);
const { result, loading, refetch } = useQuery(GET_BOOKS);

watch(result, (res) => {
  if (res?.books) {
    books.value = res.books;
  }
});

const { mutate: deleteBookMutation } = useMutation(DELETE_BOOK);

const deleteBook = async (id: string) => {
  await deleteBookMutation({ id });
  await refetch(); // refresh list after deletion
};
</script>

📄 src/views/BookForm.vue

<template>
  <form @submit.prevent="submit">
    <input v-model="title" placeholder="Title" required />
    <input v-model="author" placeholder="Author" required />
    <input v-model.number="publishedYear" placeholder="Year" type="number" />
    <button type="submit">Add Book</button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useMutation } from '@vue/apollo-composable';
import { CREATE_BOOK } from '@/graphql/mutations';
import { useRouter } from 'vue-router';

const title = ref('');
const author = ref('');
const publishedYear = ref<number | null>(null);

const { mutate } = useMutation(CREATE_BOOK);
const router = useRouter();

const submit = async () => {
  await mutate({
    title: title.value,
    author: author.value,
    publishedYear: publishedYear.value,
  });
  router.push('/');
};
</script>


11. Edit and Update a Book

Install Vue Router:

npm install vue-router

📄 src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router';
import BookList from '@/views/BookList.vue';
import BookForm from '@/views/BookForm.vue';

const routes = [
  { path: '/', component: BookList },
  { path: '/add', component: BookForm },
  { path: '/edit/:id', component: BookForm, props: true }, // ✅ new
];

export default createRouter({
  history: createWebHistory(),
  routes,
});

📄 src/App.vue

<template>
  <v-app>
    <v-main>
      <router-view />
    </v-main>
  </v-app>
</template>

<script setup lang="ts">
// no script needed here
</script>

<style scoped>
/* Optional: add some spacing */
.v-main {
  padding: 16px;
}
</style>

📄 src/views/BookList.vue (inside v-for)

<button @click="router.push(`/edit/${book.id}`)">Edit</button>

📄 src/views/BookForm.vue

<template>
  <form @submit.prevent="submit">
    <input v-model="title" placeholder="Title" required />
    <input v-model="author" placeholder="Author" required />
    <input v-model.number="publishedYear" placeholder="Year" type="number" />
    <button type="submit">{{ isEdit ? 'Update' : 'Add' }} Book</button>
  </form>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useQuery, useMutation } from '@vue/apollo-composable';
import { GET_BOOKS, GET_BOOK } from '@/graphql/queries';
import { CREATE_BOOK, UPDATE_BOOK } from '@/graphql/mutations';

const route = useRoute();
const router = useRouter();

const title = ref('');
const author = ref('');
const publishedYear = ref<number | null>(null);
const id = route.params.id as string | undefined;
const isEdit = computed(() => !!id);

// Load book if editing
if (isEdit.value) {
  const { result } = useQuery(GET_BOOK, { id });
  result.watch((res) => {
    if (res?.book) {
      title.value = res.book.title;
      author.value = res.book.author;
      publishedYear.value = res.book.publishedYear;
    }
  });
}

// Mutations
const { mutate: createBook } = useMutation(CREATE_BOOK, {
  update: (cache) => cache.evict({ fieldName: 'books' }), // optional cache update
});

const { mutate: updateBook } = useMutation(UPDATE_BOOK);

const submit = async () => {
  if (isEdit.value) {
    await updateBook({ id, title: title.value, author: author.value, publishedYear: publishedYear.value });
  } else {
    await createBook({ title: title.value, author: author.value, publishedYear: publishedYear.value });
  }
  router.push('/');
};
</script>


12. Styling using Vuetify

From your Vue project root:

npm install vuetify@3

Then install peer dependencies (if not already):

npm install sass sass-loader@^13

Create a new file:

📄 src/plugins/vuetify.ts

import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import 'vuetify/styles'; // Ensure Vuetify base styles are loaded

const vuetify = createVuetify({
  components,
  directives,
});

export default vuetify;

📄 src/main.js

import "./assets/main.css";

import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import vuetify from './plugins/vuetify';
import ApolloPlugin from './plugins/apollo';

const app = createApp(App);
app.use(router);
app.use(vuetify);
app.use(ApolloPlugin);
app.mount('#app');

📄 BookList.vue

<template>
  <v-container>
    <v-row>
      <v-col>
        <v-btn color="primary" @click="router.push('/add')">Add New Book</v-btn>
      </v-col>
    </v-row>

    <v-row>
      <v-col cols="12" md="6" v-for="book in books" :key="book.id">
        <v-card>
          <v-card-title class="text-h6">{{ book.title }}</v-card-title>
          <v-card-subtitle>
            {{ book.author }} — {{ book.publishedYear }}
          </v-card-subtitle>
          <v-card-actions>
            <v-btn color="info" @click="router.push(`/edit/${book.id}`)">Edit</v-btn>
            <v-btn color="error" @click="deleteBook(book.id)">Delete</v-btn>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { useQuery, useMutation } from '@vue/apollo-composable';
import { GET_BOOKS } from '@/graphql/queries';
import { DELETE_BOOK } from '@/graphql/mutations';
import { useRouter } from 'vue-router';

const router = useRouter();
const books = ref<any[]>([]);
const { result, loading, refetch } = useQuery(GET_BOOKS);

watch(result, (res) => {
  if (res?.books) books.value = res.books;
});

const { mutate: deleteBookMutation } = useMutation(DELETE_BOOK);
const deleteBook = async (id: string) => {
  await deleteBookMutation({ id });
  await refetch();
};
</script>

📄 BookForm.vue

<template>
  <v-container>
    <v-form @submit.prevent="submit" ref="formRef">
      <v-text-field
        v-model="title"
        label="Title"
        required
      />
      <v-text-field
        v-model="author"
        label="Author"
        required
      />
      <v-text-field
        v-model.number="publishedYear"
        label="Published Year"
        type="number"
      />
      <v-btn type="submit" color="primary">
        {{ isEdit ? 'Update' : 'Add' }} Book
      </v-btn>
    </v-form>
  </v-container>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useQuery, useMutation } from '@vue/apollo-composable';
import { GET_BOOK } from '@/graphql/queries';
import { CREATE_BOOK, UPDATE_BOOK } from '@/graphql/mutations';

const title = ref('');
const author = ref('');
const publishedYear = ref<number | null>(null);
const formRef = ref();

const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const isEdit = computed(() => !!id);

if (isEdit.value) {
  const { result } = useQuery(GET_BOOK, { id });
  watch(result, (res) => {
    if (res?.book) {
      title.value = res.book.title;
      author.value = res.book.author;
      publishedYear.value = res.book.publishedYear;
    }
  });
}

const { mutate: createBook } = useMutation(CREATE_BOOK);
const { mutate: updateBook } = useMutation(UPDATE_BOOK);

const submit = async () => {
  if (isEdit.value) {
    await updateBook({ id, title: title.value, author: author.value, publishedYear: publishedYear.value });
  } else {
    await createBook({ title: title.value, author: author.value, publishedYear: publishedYear.value });
  }

  router.push('/');
};
</script>

Done!

You now have a fully styled Vuetify-powered Vue 3 + Apollo CRUD app with:

  • Beautiful cards and buttons

  • Responsive layout using Vuetify Grid

  • Form inputs and form validation-ready components

13. Run and Test GraphQL CRUD

We assume the PostgreSQL server is already running, so you can just run Node/Express.js application and the Vue app in separate terminal tabs.

nodemon
cd client
npm run dev

Next, open the browser, then go to this address `http://localhost:5173/` and you should see these pages.

Build a Full‑Stack CRUD App with Node.js, Express, PostgreSQL & Vue 3 + GraphQL (2025 Edition) - Book List
Build a Full‑Stack CRUD App with Node.js, Express, PostgreSQL & Vue 3 + GraphQL (2025 Edition) - Book Details
Build a Full‑Stack CRUD App with Node.js, Express, PostgreSQL & Vue 3 + GraphQL (2025 Edition) - Edit Book

You can find the full source code on our GitHub.

That's just the basics. If you need more deep learning about Node.js, Express.js, PostgreSQL, Vue.js, and GraphQL, or related, you can take the following cheap course:

Thanks!