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 useconfig/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.
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:
- Node. JS
- Learning Express JS: Creating Future Web Apps & REST APIs
- Angular + NodeJS + PostgreSQL + Sequelize
- Build 7 Real World Applications with Vue. js
- Full-Stack Vue with GraphQL - The Ultimate Guide
Thanks!