DEV Community

A0mineTV
A0mineTV

Posted on

Building a Full-Stack User Management System with Prisma and Vue 3 (Nuxt 3)

In this article, we'll explore how to build a complete CRUD (Create, Read, Update, Delete) user management system using Prisma as our database ORM and Vue 3 with Nuxt 3 for the frontend. This example demonstrates modern full-stack development practices with TypeScript.

πŸ—οΈ Project Structure

Our project follows a clean architecture pattern:

  • Prisma for database schema and ORM
  • Nuxt 3 for the full-stack framework
  • Vue 3 with Composition API for reactive components
  • SQLite as our database (easily switchable to PostgreSQL/MySQL)

πŸ“Š Database Schema with Prisma

Let's start with our Prisma schema (prisma/schema.prisma):

datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model User { id Int @id @default(autoincrement()) email String @unique name String created_at DateTime @default(now()) } 
Enter fullscreen mode Exit fullscreen mode

This simple schema defines a User model with:

  • Auto-incrementing ID as primary key
  • Unique email constraint
  • Required name field
  • Automatic timestamp for creation

πŸ”§ Setting Up Prisma Client

We create a database utility file (server/utils/db.ts) to manage our Prisma client:

import { PrismaClient } from '@prisma/client' const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } export const prisma = globalForPrisma.prisma ?? new PrismaClient() if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma 
Enter fullscreen mode Exit fullscreen mode

This pattern ensures we don't create multiple Prisma instances during development (hot reloading) while maintaining a clean client in production.

πŸ› οΈ API Routes with Nuxt 3

Nuxt 3's file-based API routing makes creating RESTful endpoints straightforward. Here are our complete API routes:

Get All Users (server/api/users.get.ts)

import { prisma } from '../utils/db' export default defineEventHandler(async (event) => { const users = await prisma.user.findMany({ orderBy: { id: 'desc' } }) return users }) 
Enter fullscreen mode Exit fullscreen mode

Create New User (server/api/users.post.ts)

import { prisma } from '../utils/db' export default defineEventHandler(async (event) => { const body = await readBody(event) if (!body?.email || !body?.name) { throw createError({ statusCode: 400, statusMessage: 'Email and name are required' }) } return prisma.user.create({ data: { name: body.name, email: body.email } }) }) 
Enter fullscreen mode Exit fullscreen mode

Get Single User (server/api/users/[id].get.ts)

import { prisma } from '../../utils/db' export default defineEventHandler(async (event) => { const id = Number(getRouterParam(event, 'id')) const user = await prisma.user.findUnique({ where: { id } }) if (!user) { throw createError({ statusCode: 404, statusMessage: 'User not found' }) } return user }) 
Enter fullscreen mode Exit fullscreen mode

Update User (server/api/users/[id].put.ts)

import { prisma } from '../../utils/db' export default defineEventHandler(async (event) => { const id = Number(getRouterParam(event, 'id')) const b = await readBody(event) if (!b?.email || !b?.name) { throw createError({ statusCode: 400, statusMessage: 'Email and name are required' }) } return prisma.user.update({ where: { id }, data: { name: b.name, email: b.email } }) }) 
Enter fullscreen mode Exit fullscreen mode

Delete User (server/api/users/[id].delete.ts)

import { prisma } from "~~/server/utils/db" export default defineEventHandler(async (event) => { const id = Number(getRouterParam(event, 'id')) await prisma.user.delete({ where: { id } }) return {ok: true} }) 
Enter fullscreen mode Exit fullscreen mode

🎨 Vue 3 Frontend Components

Now let's build our Vue 3 components using the Composition API:

Users List Page (app/pages/users/index.vue)

<script setup lang="ts"> const { $notyf } = useNuxtApp() const { data: users, refresh: refreshData, error, pending } = await useAsyncData('users', () => $fetch('/api/users')) watch(error, (e) => { if (e) $notyf.error(e.message || 'Failed to load users') }, { immediate: true }) async function remove(id: number) { if (!confirm('Delete this user?')) return try { await $fetch(`/api/users/${id}`, { method: 'DELETE' }) refreshData() $notyf.success('User deleted successfully') } catch (e: any) { $notyf.error(e?.data?.message || e?.message || 'Failed to delete user') } } </script> <template> <main class="wrap"> <h1>Users</h1> <NuxtLink to="/users/new" class="btn">βž• New</NuxtLink> <p v-if="error">Error: {{ error.message }}</p> <table v-else> <thead> <tr> <th>ID</th> <th>Name</th> <th>Email</th> </tr> </thead> <tbody> <tr v-for="u in users" :key="u.id"> <td>{{ u.id }}</td> <td> <NuxtLink :to="`/users/${u.id}`">{{ u.name }}</NuxtLink> </td> <td>{{ u.email }}</td> <td> <button @click="remove(u.id)"> {{ pending ? 'Deleting...' : 'Delete' }} </button> </td> </tr> </tbody> </table> </main> </template> 
Enter fullscreen mode Exit fullscreen mode

Create User Page (app/pages/users/new.vue)

<script setup lang="ts"> const form = reactive({ name: '', email: '' }) const err = ref<string | null>(null) const router = useRouter() async function submit() { err.value = null try { await $fetch('/api/users', { method: 'POST', body: form }) router.push('/users') } catch (e: any) { err.value = e?.data?.message || e?.message || 'Failed to create user' } } </script> <template> <main class="wrap"> <h1>New User</h1> <form @submit.prevent="submit"> <input v-model="form.name" placeholder="Name" required /> <input v-model="form.email" placeholder="Email" required /> <button>Create</button> <p v-if="err" class="err">{{ err }}</p> </form> </main> </template> 
Enter fullscreen mode Exit fullscreen mode

Edit User Page (app/pages/users/[id].vue)

<script setup lang="ts"> const route = useRoute() const {data: user, error, refresh} = await useAsyncData(`user-${route.params.id}`, () => $fetch(`/api/users/${route.params.id}`)) const form = reactive({name: user.value?.name ?? '', email: user.value?.email ?? ''}) const err = ref<string | null>(null) async function save() { err.value = null try { await $fetch(`/api/users/${route.params.id}`, { method: 'PUT', body: form }) await refresh() } catch (e: any) { err.value = e?.data?.message || e?.message || 'Failed to update user' } } </script> <template> <main class="wrap"> <h1>User #{{ route.params.id }}</h1> <p v-if="error">Error: {{ error.message }}</p> <form v-else @submit.prevent="save"> <input v-model="form.name" required /> <input v-model="form.email" required /> <button>Save</button> <p v-if="err" class="err">{{ err }}</p> </form> </main> </template> 
Enter fullscreen mode Exit fullscreen mode

πŸš€ Key Features & Benefits

Type Safety

  • Prisma generates TypeScript types automatically from your schema
  • Vue 3 + TypeScript provides compile-time type checking
  • End-to-end type safety from database to UI

Modern Patterns

  • Composition API for better code organization and reusability
  • File-based routing with Nuxt 3's automatic route generation
  • Server-side rendering for better SEO and performance

Developer Experience

  • Hot reloading during development
  • Automatic API route generation based on file structure
  • Built-in error handling with proper HTTP status codes

Data Management

  • Reactive data with Vue's reactivity system
  • Optimistic updates and real-time UI feedback
  • Error handling with user-friendly notifications

πŸ”” Notification System with Notyf

To enhance user experience, we integrate Notyf for elegant notifications. First, let's create a Nuxt plugin (app/plugins/notyf.client.ts):

import { Notyf } from 'notyf' import 'notyf/notyf.min.css' export default defineNuxtPlugin(() => { const notyf = new Notyf({ duration: 3000, position: { x: 'right', y: 'top' }, types: [ {type: 'success', background: '#000', icon: false}, {type: 'error', background: '#000', icon: false}, {type: 'info', background: '#000', icon: false}, ] }) return {provide: {notyf}} }) 
Enter fullscreen mode Exit fullscreen mode

This plugin:

  • Auto-injects Notyf CSS globally
  • Customizes notification appearance with black background
  • Positions notifications in top-right corner
  • Makes available across all components via useNuxtApp()

The notifications are used throughout our Vue components for user feedback on CRUD operations, as seen in the users list page where we display success/error messages for delete operations.

πŸ“¦ Package Dependencies

{ "dependencies": { "@prisma/client": "^6.16.2", "notyf": "^3.10.0", "nuxt": "^4.1.2", "vue": "^3.5.21" }, "devDependencies": { "prisma": "^6.16.2" } } 
Enter fullscreen mode Exit fullscreen mode

🎯 Conclusion

This example demonstrates how modern web development can be both powerful and developer-friendly. The combination of:

  • Prisma for type-safe database operations
  • Vue 3 with Composition API for reactive UIs
  • Nuxt 3 for full-stack development

Creates a robust foundation for building scalable web applications. The entire CRUD system is implemented with minimal boilerplate while maintaining type safety and excellent developer experience.

The file-based routing system, automatic API generation, and reactive data management make this stack particularly well-suited for rapid prototyping and production applications alike.

Top comments (0)