DEV Community

Abdur Rakib Rony
Abdur Rakib Rony

Posted on

Building a Full-Stack User Management System with Next.js 14, GraphQL, Prisma, and PostgreSQL

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

GraphQL Schema
Create app/graphql/schema.jsto 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!
  }
`;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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} />;
}

Enter fullscreen mode Exit fullscreen mode

Setting Up Environment Variables
Create a .env file in your root directory:

DATABASE_URL="postgresql://username:password@localhost:5432/your_database_name"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Initialize Prisma

npx prisma generate
npx prisma db push
Enter fullscreen mode Exit fullscreen mode

Go to code

https://github.com/abdur-rakib-rony/nextjs-graphql-postgres-prisma-crud-operation
Enter fullscreen mode Exit fullscreen mode

Happy coding! 🚀

Top comments (0)