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()) }
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
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 })
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 } }) })
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 })
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 } }) })
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} })
π¨ 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>
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>
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>
π 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}} })
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" } }
π― 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)