Building a Full-Stack User Management System with Next.js 14, GraphQL, Prisma, and PostgreSQL
Abdur Rakib Rony
Posted on November 10, 2024
In this comprehensive guide, we'll build a complete user management system using modern web technologies. You can find the complete source code in my GitHub repository.
Prerequisites
- Node.js installed on your machine
- PostgreSQL database
- Basic knowledge of React and Next.js
- Understanding of GraphQL concepts
Project Setup
First, create a new Next.js project and install the required dependencies:
npx create-next-app@latest graphql-user-management
cd graphql-user-management
npm install @apollo/server @as-integrations/next @prisma/client graphql-tag lucide-react
npm install -D prisma tailwindcss postcss
Project Structure
├── app/
│ ├── actions/
│ │ └── userActions.js
│ ├── api/
│ │ └── graphql/
│ │ └── route.js
│ ├── components/
│ │ └── UserManagement.jsx
│ ├── graphql/
│ │ └── schema.js
│ ├── lib/
│ │ └── prisma.js
│ ├── page.js
│ └── layout.js
├── prisma/
│ └── schema.prisma
└── package.json
Database Setup with Prisma
First, let's set up our database schema using Prisma. Create a new file prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
GraphQL Schema
Create app/graphql/schema.js
to define our GraphQL types and operations:
import { gql } from "graphql-tag";
export const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
createdAt: String!
updatedAt: String!
}
type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String, email: String): User!
deleteUser(id: ID!): Boolean!
}
`;
Prisma Client Setup
Create app/lib/prisma.js
to initialize the Prisma client:
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma
GraphQL API Route
Create app/api/graphql/route.js
to set up Apollo Server:
import { ApolloServer } from "@apollo/server";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { typeDefs } from "@/graphql/schema";
import prisma from "@/lib/prisma";
const resolvers = {
Query: {
users: async () => {
const users = await prisma.user.findMany({
orderBy: {
createdAt: "desc",
},
});
return users;
},
user: async (_, { id }) => {
const user = await prisma.user.findUnique({
where: {
id: parseInt(id),
},
});
return user;
},
},
Mutation: {
createUser: async (_, { name, email }) => {
try {
const user = await prisma.user.create({
data: {
name,
email,
},
});
return user;
} catch (error) {
if (error.code === "P2002") {
throw new Error("A user with this email already exists");
}
throw error;
}
},
updateUser: async (_, { id, name, email }) => {
try {
const user = await prisma.user.update({
where: {
id: parseInt(id),
},
data: {
name,
email,
},
});
return user;
} catch (error) {
if (error.code === "P2002") {
throw new Error("A user with this email already exists");
}
throw error;
}
},
deleteUser: async (_, { id }) => {
try {
await prisma.user.delete({
where: {
id: parseInt(id),
},
});
return true;
} catch (error) {
return false;
}
},
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
const handler = startServerAndCreateNextHandler(server);
export { handler as GET, handler as POST };
Server Actions
Create app/actions/userActions.js
to handle server-side mutations:
"use server";
import { revalidatePath } from "next/cache";
async function fetchGraphQL(query, variables = {}) {
try {
const response = await fetch("http://localhost:3000/api/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
cache: "no-store",
next: {
tags: ["users"],
revalidate: 0
}
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
return result;
} catch (error) {
throw new Error(error.message || "An error occurred");
}
}
export async function getUsers() {
try {
const { data } = await fetchGraphQL(`
query GetUsers {
users {
id
name
email
createdAt
updatedAt
}
}
`);
return { users: data.users };
} catch (error) {
return { error: error.message };
}
}
export async function createUser(name, email) {
try {
const { data } = await fetchGraphQL(
`
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) {
id
name
email
}
}
`,
{ name, email }
);
revalidatePath("/");
return { success: true, user: data.createUser };
} catch (error) {
return { error: error.message };
}
}
export async function updateUser(id, name, email) {
try {
const { data } = await fetchGraphQL(
`
mutation UpdateUser($id: ID!, $name: String!, $email: String!) {
updateUser(id: $id, name: $name, email: $email) {
id
name
email
}
}
`,
{ id, name, email }
);
revalidatePath("/");
return { success: true, user: data.updateUser };
} catch (error) {
return { error: error.message };
}
}
export async function deleteUser(id) {
try {
await fetchGraphQL(
`
mutation DeleteUser($id: ID!) {
deleteUser(id: $id)
}
`,
{ id }
);
revalidatePath("/");
return { success: true };
} catch (error) {
return { error: error.message };
}
}
User Management Component
Create app/components/UserManagement.jsx
:
"use client";
import React, { useState, useTransition } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { createUser, updateUser, deleteUser } from '@/app/actions/userActions';
function UserManagement({ users = [] }) {
const [isPending, startTransition] = useTransition();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [editingUser, setEditingUser] = useState(null);
const [error, setError] = useState(null);
const handleCreateUser = async (e) => {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
const result = await createUser(name, email);
if (result.error) {
setError(result.error);
} else {
setName("");
setEmail("");
}
} catch (err) {
setError(err.message);
}
});
};
const handleUpdateUser = async (e) => {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
const result = await updateUser(editingUser.id, name, email);
if (result.error) {
setError(result.error);
} else {
setEditingUser(null);
setName("");
setEmail("");
}
} catch (err) {
setError(err.message);
}
});
};
const handleDeleteUser = async (id) => {
setError(null);
startTransition(async () => {
try {
const result = await deleteUser(id);
if (result.error) {
setError(result.error);
}
} catch (err) {
setError(err.message);
}
});
};
return (
<div className="bg-white rounded-lg shadow-md w-full max-w-4xl mx-auto mt-8">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">User Management</h2>
</div>
<div className="p-6">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-md">
{error}
</div>
)}
<form
onSubmit={editingUser ? handleUpdateUser : handleCreateUser}
className="space-y-4 mb-8"
>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
disabled={isPending}
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
disabled={isPending}
required
/>
<button
type="submit"
className={`w-full py-2 px-4 rounded-md text-white font-medium
${isPending ? "bg-blue-400 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-600"}`}
disabled={isPending}
>
{isPending ? (
<svg className="animate-spin h-5 w-5 mx-auto" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : editingUser ? (
"Update User"
) : (
"Create User"
)}
</button>
{editingUser && (
<button
type="button"
onClick={() => {
setEditingUser(null);
setName("");
setEmail("");
}}
className="w-full py-2 px-4 border border-gray-300 rounded-md text-gray-700 font-medium hover:bg-gray-50 disabled:bg-gray-100"
disabled={isPending}
>
Cancel Edit
</button>
)}
</form>
{isPending && (
<div className="fixed top-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-md shadow-lg">
Saving changes...
</div>
)}
<div className="space-y-4">
{users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-md"
>
<div>
<h3 className="font-medium">{user.name}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => {
setEditingUser(user);
setName(user.name);
setEmail(user.email);
}}
disabled={isPending}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-md disabled:opacity-50"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteUser(user.id)}
disabled={isPending}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}
export default UserManagement;
Root Page Component
Create app/page.js
:
import UserManagement from "./components/UserManagement";
import { getUsers } from "./actions/userActions";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export default async function HomePage() {
const { users, error } = await getUsers();
if (error) {
return <div>Error loading users: {error}</div>;
}
return <UserManagement users={users} />;
}
Setting Up Environment Variables
Create a .env
file in your root directory:
DATABASE_URL="postgresql://username:password@localhost:5432/your_database_name"
Replace the values with your actual PostgreSQL database credentials.
Package.json Configuration
Here's the complete package.json
configuration:
{
"name": "graphql",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@apollo/server": "^4.11.2",
"@as-integrations/next": "^3.2.0",
"@prisma/client": "^5.22.0",
"graphql-tag": "^2.12.6",
"lucide-react": "^0.456.0",
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106"
},
"devDependencies": {
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.1"
}
}
Initialize Prisma
npx prisma generate
npx prisma db push
Go to code
https://github.com/abdur-rakib-rony/nextjs-graphql-postgres-prisma-crud-operation
Happy coding! 🚀
Posted on November 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 10, 2024